diff --git a/.github/workflows/ci_pipeline.yml b/.github/workflows/ci_pipeline.yml index 6c222e7..eced47c 100644 --- a/.github/workflows/ci_pipeline.yml +++ b/.github/workflows/ci_pipeline.yml @@ -37,4 +37,7 @@ jobs: - name: Test tutorials run: | - jupyter nbconvert --to notebook --execute tutorials/*.ipynb --output-dir=/tmp --ExecutePreprocessor.timeout=300 + # tutorial_07_tikz.ipynb requires pdflatex — skip it in CI + jupyter nbconvert --to notebook --execute \ + $(ls tutorials/*.ipynb | grep -v tutorial_07_tikz) \ + --output-dir=/tmp --ExecutePreprocessor.timeout=300 diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index e6282db..e3fa1e5 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -11,7 +11,7 @@ on: - devel jobs: - static-analysis: + cloc: runs-on: ubuntu-latest steps: - name: Checkout the code @@ -24,38 +24,103 @@ jobs: ./cloc --version ./cloc $(git ls-files) + black: + runs-on: ubuntu-latest + steps: + - name: Checkout the code + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Code formatting with black run: | - pip install black pip install "black[jupyter]" black --check src/ black --check tutorials/ + isort: + runs-on: ubuntu-latest + steps: + - name: Checkout the code + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Code formatting with isort run: | pip install isort isort --check src/ isort --check tutorials/ - - name: Code formatting with prospector + mypy: + runs-on: ubuntu-latest + steps: + - name: Checkout the code + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Type check with mypy continue-on-error: true run: | pip install mypy mypy src/ - - name: Code formatting with prospector + prospector: + runs-on: ubuntu-latest + steps: + - name: Checkout the code + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Static analysis with prospector continue-on-error: true run: | pip install prospector prospector src/ - - - name: Code formatting with ruff + + ruff: + runs-on: ubuntu-latest + steps: + - name: Checkout the code + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Lint with ruff continue-on-error: true run: | pip install ruff ruff check src/ - - name: Code formatting with pylint + pylint: + runs-on: ubuntu-latest + steps: + - name: Checkout the code + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Lint with pylint continue-on-error: true run: | pip install pylint diff --git a/.gitignore b/.gitignore index 4f40a94..515f6e9 100644 --- a/.gitignore +++ b/.gitignore @@ -164,7 +164,7 @@ cython_debug/ # Figures and videos figures/ -*.png +#*.png *.pdf *.jpg *.jpeg @@ -188,4 +188,12 @@ figures/ env* # VS code -.vscode/* \ No newline at end of file +.vscode/* + +# Outputs +docs/.astro/ +docs/node_modules/ +docs/superpowers/ +docs/tutorials/ +tutorials/*.txt +tutorials/*.png diff --git a/README.md b/README.md index 1b21732..1eac2d2 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,123 @@ -# maxplotlib +# Maxplotlib -This is a wrapper for matplotlib so I can produce figures with consistent formatting. It also has some pretty nice additions such as using layers and exporting to tikz. +A clean, expressive wrapper around **Matplotlib**, **Plotly**, +**plotext**, and **tikzfigure** for producing publication-quality +figures with minimal boilerplate. Swap backends without rewriting your +data — render the same canvas as a crisp PNG, an interactive Plotly +chart, a terminal-native plotext figure, or camera-ready **TikZ** code +for LaTeX. ## Install -Create and activate python environment +``` bash +pip install maxplotlibx +``` + +## Showcase + +### Quickstart + +``` python +import numpy as np +from maxplotlib import Canvas + +x = np.linspace(0, 2 * np.pi, 200) +y = np.sin(x) + +canvas, ax = Canvas.subplots() +ax.plot(x, y) +``` + +Plot the figure with the default (matplotlib) backend: + +``` python +canvas.show() +``` + +![](README_files/figure-markdown_strict/cell-3-output-1.png) + +Render the same line graph directly in the terminal with the `plotext` +backend: + +``` python +terminal_fig = canvas.plot(backend="plotext") +print(terminal_fig.build(keep_colors=False)) +``` + +Or plot with the TikZ backend: +``` python +canvas.show(backend="tikzfigure") ``` -python -m venv env -source env/bin/activate -pip install --upgrade pip + +![](README_files/figure-markdown_strict/cell-4-output-1.png) + +### Terminal backend + +The `plotext` backend is designed for terminal-first workflows. It +currently supports line plots, scatter plots, bars, filled regions, +error bars, reference lines, text/annotations, labels/titles, log +axes, layers, matrix-style `imshow()` rendering, common patches, and +multi-subplot canvases. + +``` python +x = np.linspace(1, 10, 40) + +canvas, ax = Canvas.subplots() +ax.plot(x, np.sqrt(x), color="cyan", label="sqrt(x)") +ax.errorbar(x[::8], np.sqrt(x[::8]), yerr=0.15, color="yellow", label="samples") +ax.set_title("Terminal plot") +ax.set_xlabel("x") +ax.set_ylabel("y") +ax.set_xscale("log") +ax.set_legend(True) + +canvas.show(backend="plotext") ``` -Install the code and requirements with pip +## Examples +Runnable example scripts live in `examples/`: + +``` bash +python examples/plotly_backend_basic.py +python examples/plotly_backend_parity.py ``` -pip install -e . + +### Layers + +``` python +x = np.linspace(0, 2 * np.pi, 200) + +canvas, ax = Canvas.subplots(width="10cm", ratio=0.55) + +ax.plot(x, np.sin(x), color="steelblue", label=r"$\sin(x)$", layer=0) +ax.plot(x, np.cos(x), color="tomato", label=r"$\cos(x)$", layer=1) +ax.plot( + x, + np.sin(x) * np.cos(x), + color="seagreen", + label=r"$\sin(x)\cos(x)$", + linestyle="dashed", + layer=2, +) + +ax.set_xlabel("x") +ax.set_legend(True) ``` -Additional dependencies for developers can be installed with +Show layer 0 only, then layers 0 and 1, then everything: +``` python +canvas.show(layers=[0]) ``` -pip install -e ".[dev]" + +![](README_files/figure-markdown_strict/cell-6-output-1.png) + +Show all layers: + +``` python +canvas.show() ``` -Some examples can be found in `tutorials/` \ No newline at end of file +![](README_files/figure-markdown_strict/cell-7-output-1.png) diff --git a/README.qmd b/README.qmd new file mode 100644 index 0000000..320ef78 --- /dev/null +++ b/README.qmd @@ -0,0 +1,139 @@ +--- +title: Maxlotlib +format: gfm +fig-dpi: 150 +--- + +# Maxplotlib + +A clean, expressive wrapper around **Matplotlib**, **Plotly**, **plotext**, and **tikzfigure** +for producing publication-quality figures with minimal boilerplate. Swap backends without +rewriting your data — render the same canvas as a crisp PNG, an interactive Plotly chart, a +terminal-native plotext figure, or camera-ready **TikZ** code for LaTeX. + +## Install + +```bash +pip install maxplotlibx +``` + +## Showcase + +### Quickstart + +```{python} +#| label: fig-showcase-1 +#| fig-width: 9 +#| fig-height: 6 + +import numpy as np +from maxplotlib import Canvas + +x = np.linspace(0, 2 * np.pi, 200) +y = np.sin(x) + +canvas, ax = Canvas.subplots() +ax.plot(x, y) +``` + +Plot the figure with the default (matplotlib) backend: + +```{python} +canvas.show() +``` + +Render the same line graph directly in the terminal with the `plotext` backend: + +```{python} +terminal_fig = canvas.plot(backend="plotext") +print(terminal_fig.build(keep_colors=False)) +``` + +Or plot with the TikZ backend: + +```{python} +canvas.show(backend="tikzfigure") +``` + +### Horizontal Subplots with TikZ Backend + +The tikzfigure backend supports creating side-by-side subplots (1×n layouts): + +```{python} +#| label: fig-showcase-subplots +#| fig-width: 9 +#| fig-height: 6 + +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 +``` + +**Note:** Only horizontal layouts (1×n) are currently supported with the tikzfigure backend. Vertical/grid layouts will raise `NotImplementedError`. See the tutorials for more examples. + +### Terminal Backend with plotext + +The `plotext` backend is designed for terminal-first workflows. It currently supports line plots, +scatter plots, bars, filled regions, error bars, reference lines, text/annotations, labels/titles, +log axes, layers, matrix-style `imshow()` rendering, common patches, and multi-subplot canvases. + +```{python} +x = np.linspace(1, 10, 40) + +canvas, ax = Canvas.subplots() +ax.plot(x, np.sqrt(x), color="cyan", label="sqrt(x)") +ax.errorbar(x[::8], np.sqrt(x[::8]), yerr=0.15, color="yellow", label="samples") +ax.set_title("Terminal plot") +ax.set_xlabel("x") +ax.set_ylabel("y") +ax.set_xscale("log") +ax.set_legend(True) + +canvas.show(backend="plotext") +``` + +### Layers + +```{python} +#| label: fig-showcase-2 +#| fig-width: 9 +#| fig-height: 6 + +x = np.linspace(0, 2 * np.pi, 200) + +canvas, ax = Canvas.subplots(width="10cm", ratio=0.55) + +ax.plot(x, np.sin(x), color="steelblue", label=r"$\sin(x)$", layer=0) +ax.plot(x, np.cos(x), color="tomato", label=r"$\cos(x)$", layer=1) +ax.plot( + x, + np.sin(x) * np.cos(x), + color="seagreen", + label=r"$\sin(x)\cos(x)$", + linestyle="dashed", + layer=2, +) + +ax.set_xlabel("x") +ax.set_legend(True) +``` + +Show layer 0 only, then layers 0 and 1, then everything: + +```{python} +canvas.show(layers=[0]) +``` + +Show all layers: + +```{python} +canvas.show() +``` diff --git a/README_files/figure-markdown_strict/cell-3-output-1.png b/README_files/figure-markdown_strict/cell-3-output-1.png new file mode 100644 index 0000000..47c9972 Binary files /dev/null and b/README_files/figure-markdown_strict/cell-3-output-1.png differ diff --git a/README_files/figure-markdown_strict/cell-4-output-1.png b/README_files/figure-markdown_strict/cell-4-output-1.png new file mode 100644 index 0000000..34530c5 Binary files /dev/null and b/README_files/figure-markdown_strict/cell-4-output-1.png differ diff --git a/README_files/figure-markdown_strict/cell-6-output-1.png b/README_files/figure-markdown_strict/cell-6-output-1.png new file mode 100644 index 0000000..56f10fb Binary files /dev/null and b/README_files/figure-markdown_strict/cell-6-output-1.png differ diff --git a/README_files/figure-markdown_strict/cell-7-output-1.png b/README_files/figure-markdown_strict/cell-7-output-1.png new file mode 100644 index 0000000..95dde0b Binary files /dev/null and b/README_files/figure-markdown_strict/cell-7-output-1.png differ diff --git a/docs/source/conf.py b/docs/source/conf.py index 03dcf32..acbc723 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -39,7 +39,10 @@ def setup(app): ] templates_path = ["_templates"] -exclude_patterns = [] +exclude_patterns = [ + # tutorial_07_tikz.ipynb requires pdflatex to render — skip during docs build + "tutorials/tutorial_07_tikz.ipynb", +] # -- Options for HTML output ------------------------------------------------- diff --git a/docs/source/index.rst b/docs/source/index.rst index 6fb89fc..3b75b14 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -21,3 +21,7 @@ documentation for details. tutorials/tutorial_04 tutorials/tutorial_05 tutorials/tutorial_06 + tutorials/tutorial_07_tikz + tutorials/tutorial_08_plotly + tutorials/tutorial_09_plotext + tutorials/tutorial_tikzfigure_subplots diff --git a/examples/plotly_backend_basic.py b/examples/plotly_backend_basic.py new file mode 100644 index 0000000..1278b49 --- /dev/null +++ b/examples/plotly_backend_basic.py @@ -0,0 +1,23 @@ +import numpy as np + +from maxplotlib import Canvas + + +def main() -> None: + x = np.linspace(0, 2 * np.pi, 200) + + canvas = Canvas(width="12cm", ratio=0.5) + canvas.add_line(x, np.sin(x), color="royalblue", label="sin(x)") + canvas.scatter(x[::12], np.sin(x[::12]), color="tomato", label="samples") + canvas.axhline(0, color="black", linestyle="dotted") + canvas.set_title("Plotly backend (basic)") + canvas.set_xlabel("x") + canvas.set_ylabel("y") + canvas.set_grid(True) + canvas.set_legend(True) + + canvas.savefig("plotly_basic.html", backend="plotly") + + +if __name__ == "__main__": + main() diff --git a/examples/plotly_backend_parity.py b/examples/plotly_backend_parity.py new file mode 100644 index 0000000..67a19a7 --- /dev/null +++ b/examples/plotly_backend_parity.py @@ -0,0 +1,47 @@ +import matplotlib.patches as mpatches +import numpy as np + +from maxplotlib import Canvas + + +def main() -> None: + x = np.linspace(0.5, 10, 60) + y = np.sqrt(x) + + canvas = Canvas(width="12cm", ratio=0.55) + + canvas.add_line(x, y, color="steelblue", label="sqrt(x)") + canvas.errorbar( + x[::10], + y[::10], + yerr=0.15, + color="tomato", + marker="o", + label="samples ± err", + ) + canvas.fill_between(x, y - 0.1, y + 0.1, color="steelblue", alpha=0.2, label="band") + canvas.vlines([2, 5, 8], ymin=0, ymax=3.5, color="gray", linestyle="dashed") + canvas.text(7.2, 2.8, "note", color="purple") + canvas.annotate( + "peak-ish", xy=(9.5, np.sqrt(9.5)), xytext=(6.0, 3.1), color="purple" + ) + + canvas.add_patch( + mpatches.Rectangle((1.2, 0.0), 2.5, 1.2, fill=True), + facecolor="rgba(255,0,0,0.1)", + edgecolor="crimson", + alpha=0.3, + ) + + canvas.set_title("Plotly backend (parity features)") + canvas.set_xlabel("x") + canvas.set_ylabel("y") + canvas.set_xscale("log") + canvas.set_grid(True) + canvas.set_legend(True) + + canvas.savefig("plotly_parity.html", backend="plotly") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index aa4ecb8..d1c1c03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "maxplotlibx" -version = "0.1.3" +version = "0.1.4" description = "A reproducible plotting module with various backends and export options." readme = "README.md" requires-python = ">=3.8" @@ -18,7 +18,8 @@ dependencies = [ "matplotlib", "pint", "plotly", - "tikzpics>=0.1.1", + "plotext", + "tikzfigure[vis]>=0.3.0", ] [project.optional-dependencies] test = [ @@ -36,7 +37,7 @@ docs = [ dev = [ "maxplotlibx[test,docs]", "ruff", - "black", + "black[jupyter]", "isort", "jupyterlab", "nbstripout", @@ -56,4 +57,4 @@ line-length = 88 line-length = 88 [tool.isort] -profile = "black" \ No newline at end of file +profile = "black" diff --git a/src/maxplotlib/__init__.py b/src/maxplotlib/__init__.py index 5f86d34..7b715b2 100644 --- a/src/maxplotlib/__init__.py +++ b/src/maxplotlib/__init__.py @@ -1,3 +1,3 @@ -from maxplotlib.canvas.canvas import Canvas +from maxplotlib.canvas.canvas import Canvas, SubplotSpacing -__all__ = ["Canvas"] +__all__ = ["Canvas", "SubplotSpacing"] diff --git a/src/maxplotlib/backends/matplotlib/utils.py b/src/maxplotlib/backends/matplotlib/utils.py index 60786b5..bb3331d 100644 --- a/src/maxplotlib/backends/matplotlib/utils.py +++ b/src/maxplotlib/backends/matplotlib/utils.py @@ -6,7 +6,7 @@ import pint -def setup_tex_fonts(fontsize=14, usetex=False): +def setup_tex_fonts(fontsize=10, usetex=False): """ Sets up LaTeX fonts for plotting. """ @@ -15,6 +15,8 @@ def setup_tex_fonts(fontsize=14, usetex=False): "font.family": "serif", "pgf.rcfonts": False, "axes.labelsize": fontsize, + "axes.titlesize": fontsize, + "figure.titlesize": fontsize, "font.size": fontsize, "legend.fontsize": fontsize, "xtick.labelsize": fontsize, @@ -58,13 +60,13 @@ def convert_to_inches(length_str): def _2pt(width, dpi=300, verbose: bool = False): if verbose: - print(f"Converting width: {width} to points with dpi={dpi}") + print(f"Converting width: {width} to points") if isinstance(width, (int, float)): return width elif isinstance(width, str): length_in = convert_to_inches(width) - length_pt = length_in * dpi + length_pt = length_in * 72.27 if verbose: print(f"Converted length: {length_in} inches = {length_pt} points") return length_pt diff --git a/src/maxplotlib/backends/plotext/__init__.py b/src/maxplotlib/backends/plotext/__init__.py new file mode 100644 index 0000000..d0ce577 --- /dev/null +++ b/src/maxplotlib/backends/plotext/__init__.py @@ -0,0 +1,3 @@ +from maxplotlib.backends.plotext.figure import PlotextFigure, create_plotext_figure + +__all__ = ["PlotextFigure", "create_plotext_figure"] diff --git a/src/maxplotlib/backends/plotext/figure.py b/src/maxplotlib/backends/plotext/figure.py new file mode 100644 index 0000000..be07159 --- /dev/null +++ b/src/maxplotlib/backends/plotext/figure.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import re +from pathlib import Path + +from plotext._figure import _figure_class + +_ANSI_ESCAPE_RE = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]") + + +def strip_ansi(text: str) -> str: + return _ANSI_ESCAPE_RE.sub("", text) + + +def create_plotext_figure(nrows: int = 1, ncols: int = 1) -> _figure_class: + figure = _figure_class() + if nrows > 1 or ncols > 1: + figure.subplots(nrows, ncols) + return figure + + +class PlotextFigure: + def __init__(self, figure: _figure_class, suptitle: str | None = None): + self.figure = figure + self.suptitle = suptitle + + def build(self, keep_colors: bool = True) -> str: + output = self.figure.build() + if self.suptitle: + output = f"{self.suptitle}\n{output}" + return output if keep_colors else strip_ansi(output) + + def show(self) -> str: + output = self.build() + print(output) + return output + + def savefig(self, path, append: bool = False, keep_colors: bool = False) -> None: + destination = Path(path) + mode = "a" if append else "w" + with destination.open(mode, encoding="utf-8") as handle: + handle.write(self.build(keep_colors=keep_colors)) + handle.write("\n") + + save_fig = savefig + + def __getattr__(self, name): + return getattr(self.figure, name) + + def __str__(self) -> str: + return self.build(keep_colors=False) diff --git a/src/maxplotlib/canvas/canvas.py b/src/maxplotlib/canvas/canvas.py index a64520e..a321776 100644 --- a/src/maxplotlib/canvas/canvas.py +++ b/src/maxplotlib/canvas/canvas.py @@ -1,23 +1,179 @@ import os import re -from typing import Dict +from dataclasses import dataclass +from typing import Mapping import matplotlib.patches as patches import matplotlib.pyplot as plt +import numpy as np from plotly.subplots import make_subplots -from tikzpics import TikzFigure +from tikzfigure import TikzFigure from maxplotlib.backends.matplotlib.utils import ( set_size, setup_plotstyle, setup_tex_fonts, ) +from maxplotlib.backends.plotext import PlotextFigure, create_plotext_figure from maxplotlib.colors.colors import Color from maxplotlib.linestyle.linestyle import Linestyle from maxplotlib.subfigure.line_plot import LinePlot from maxplotlib.utils.options import Backends +@dataclass(frozen=True) +class SubplotSpacing: + """Typed spacing configuration for subplot grids.""" + + wspace: float = 0.08 + hspace: float = 0.1 + + def to_gridspec_kw(self) -> dict[str, float]: + return {"wspace": self.wspace, "hspace": self.hspace} + + +def _parse_bool_env_var(name: str, default: bool = False) -> bool: + value = os.getenv(name) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def plot_matplotlib(tikzfigure: TikzFigure, ax, layers=None): + """ + Plot all nodes and paths on the provided axis using Matplotlib. + + Parameters: + - ax (matplotlib.axes.Axes): Axis on which to plot the figure. + """ + + # TODO: Specify which layers to retreive nodes from with layers=layers + nodes = tikzfigure.layers.get_nodes() + paths = tikzfigure.layers.get_paths() + + for path in paths: + x_coords = [node.x for node in path.nodes] + y_coords = [node.y for node in path.nodes] + + # Parse path color + path_color_spec = path.kwargs.get("color", "black") + try: + color = Color(path_color_spec).to_rgb() + except ValueError as e: + print(e) + color = "black" + + # Parse line width + line_width_spec = path.kwargs.get("line_width", 1) + if isinstance(line_width_spec, str): + match = re.match(r"([\d.]+)(pt)?", line_width_spec) + if match: + line_width = float(match.group(1)) + else: + print( + f"Invalid line width specification: '{line_width_spec}', defaulting to 1", + ) + line_width = 1 + else: + line_width = float(line_width_spec) + + # Parse line style using Linestyle class + style_spec = path.kwargs.get("style", "solid") + linestyle = Linestyle(style_spec).to_matplotlib() + + ax.plot( + x_coords, + y_coords, + color=color, + linewidth=line_width, + linestyle=linestyle, + zorder=1, # Lower z-order to place behind nodes + ) + + # Plot nodes after paths so they appear on top + for node in nodes: + # Determine shape and size + shape = node.kwargs.get("shape", "circle") + fill_color_spec = node.kwargs.get("fill", "white") + edge_color_spec = node.kwargs.get("draw", "black") + linewidth = float(node.kwargs.get("line_width", 1)) + size = float(node.kwargs.get("size", 1)) + + # Parse colors using the Color class + try: + facecolor = Color(fill_color_spec).to_rgb() + except ValueError as e: + print(e) + facecolor = "white" + + try: + edgecolor = Color(edge_color_spec).to_rgb() + except ValueError as e: + print(e) + edgecolor = "black" + + # Plot shapes + if shape == "circle": + radius = size / 2 + circle = patches.Circle( + (node.x, node.y), + radius, + facecolor=facecolor, + edgecolor=edgecolor, + linewidth=linewidth, + zorder=2, # Higher z-order to place on top of paths + ) + ax.add_patch(circle) + elif shape == "rectangle": + width = height = size + rect = patches.Rectangle( + (node.x - width / 2, node.y - height / 2), + width, + height, + facecolor=facecolor, + edgecolor=edgecolor, + linewidth=linewidth, + zorder=2, # Higher z-order + ) + ax.add_patch(rect) + else: + # Default to circle if shape is unknown + radius = size / 2 + circle = patches.Circle( + (node.x, node.y), + radius, + facecolor=facecolor, + edgecolor=edgecolor, + linewidth=linewidth, + zorder=2, + ) + ax.add_patch(circle) + + # Add text inside the shape + if node.content: + ax.text( + node.x, + node.y, + node.content, + fontsize=self._fontsize, + ha="center", + va="center", + wrap=True, + zorder=3, # Even higher z-order for text + ) + + # Remove axes, ticks, and legend + ax.axis("off") + + # Adjust plot limits + all_x = [node.x for node in nodes] + all_y = [node.y for node in nodes] + padding = 1 # Adjust padding as needed + ax.set_xlim(min(all_x) - padding, max(all_x) + padding) + ax.set_ylim(min(all_y) - padding, max(all_y) + padding) + ax.set_aspect("equal", adjustable="datalim") + + class Canvas: def __init__( self, @@ -27,11 +183,13 @@ def __init__( caption: str | None = None, description: str | None = None, label: str | None = None, - fontsize: int = 14, - dpi: int = 300, - width: str = "17cm", + fontsize: int = 10, + dpi: int | None = None, + width: str | None = None, ratio: str = "golden", # TODO Add literal - gridspec_kw: Dict = {"wspace": 0.08, "hspace": 0.1}, + usetex: bool | None = None, + subplot_spacing: SubplotSpacing | None = None, + gridspec_kw: Mapping[str, float] | None = None, ): """ Initialize the Canvas class for multiple subplots. @@ -43,11 +201,16 @@ def __init__( caption (str): Caption for the figure. description (str): Description for the figure. label (str): Label for the figure. - fontsize (int): Font size. Default is 14. - dpi (int): DPI for the figure. Default is 300. - width (str): Width of the figure. Default is "17cm". + fontsize (int): Font size. Default is 10. + dpi (int | None): Optional export/render DPI override. + width (str | None): Optional figure width, e.g. "7cm". ratio (str): Aspect ratio. Default is "golden". - gridspec_kw (dict): Gridspec keyword arguments. Default is {"wspace": 0.08, "hspace": 0.1}. + usetex (bool | None): Default text.usetex behavior for this canvas. + If None, read from MAXPLOTLIB_USETEX environment variable. + subplot_spacing (SubplotSpacing): Typed subplot spacing. + Default is SubplotSpacing(wspace=0.08, hspace=0.1). + gridspec_kw (Mapping[str, float]): Optional matplotlib gridspec kwargs. + Kept for compatibility with existing code. """ self._nrows = nrows @@ -60,8 +223,25 @@ def __init__( self._dpi = dpi self._width = width self._ratio = ratio - self._gridspec_kw = gridspec_kw + self._usetex = ( + _parse_bool_env_var("MAXPLOTLIB_USETEX", default=False) + if usetex is None + else usetex + ) + if subplot_spacing is not None and gridspec_kw is not None: + raise ValueError("Pass either subplot_spacing or gridspec_kw, not both.") + if subplot_spacing is None and gridspec_kw is None: + subplot_spacing = SubplotSpacing() + if subplot_spacing is not None: + self._gridspec_kw = subplot_spacing.to_gridspec_kw() + else: + self._gridspec_kw = dict(gridspec_kw) self._plotted = False + self._matplotlib_fig = None + self._matplotlib_axes = None + self._plotext_figure = None + self._suptitle: str | None = None + self._suptitle_kwargs: dict = {} # Dictionary to store lines for each subplot # Key: (row, col), Value: list of lines with their data and kwargs @@ -70,14 +250,77 @@ def __init__( self._subplot_matrix = [[None] * self.ncols for _ in range(self.nrows)] + # ------------------------------------------------------------------ + # Factory + # ------------------------------------------------------------------ + + @classmethod + def subplots( + cls, + nrows: int = 1, + ncols: int = 1, + squeeze: bool = True, + wspace: float | None = None, + hspace: float | None = None, + **canvas_kwargs, + ): + """ + Create a Canvas pre-filled with LinePlot subplots, mirroring + ``matplotlib.pyplot.subplots()``. + + Parameters: + nrows, ncols (int): Grid dimensions. + squeeze (bool): If True, return a single subplot instead of a 1-element + list when the grid is 1×1 or when one dimension is 1. + wspace, hspace (float): Convenience subplot spacing arguments. + These map to matplotlib gridspec spacing values. + **canvas_kwargs: Forwarded to the Canvas constructor. + + Returns: + (canvas, axes): A tuple of the Canvas and either a single LinePlot, + a flat list (when one dimension is 1 and squeeze=True), or a + 2-D list of LinePlots. + + Examples: + >>> canvas, ax = Canvas.subplots() + >>> canvas, (ax1, ax2) = Canvas.subplots(ncols=2) + >>> canvas, axes = Canvas.subplots(nrows=2, ncols=2) # axes[row][col] + """ + spacing_given = wspace is not None or hspace is not None + if spacing_given and ( + "subplot_spacing" in canvas_kwargs or "gridspec_kw" in canvas_kwargs + ): + raise ValueError( + "Use either wspace/hspace or subplot_spacing/gridspec_kw, not both." + ) + if spacing_given: + canvas_kwargs["subplot_spacing"] = SubplotSpacing( + wspace=0.08 if wspace is None else wspace, + hspace=0.1 if hspace is None else hspace, + ) + + canvas = cls(nrows=nrows, ncols=ncols, **canvas_kwargs) + axes = [ + [canvas.add_subplot(row=r, col=c) for c in range(ncols)] + for r in range(nrows) + ] + if squeeze: + if nrows == 1 and ncols == 1: + return canvas, axes[0][0] + if nrows == 1: + return canvas, axes[0] + if ncols == 1: + return canvas, [row[0] for row in axes] + return canvas, axes + @property - def subplots(self): + def _subplot_dict(self): return self._subplots @property def layers(self): layers = [] - for (row, col), subplot in self.subplots.items(): + for (row, col), subplot in self._subplot_dict.items(): layers.extend(subplot.layers) return list(set(layers)) @@ -100,13 +343,12 @@ def generate_new_rowcol(self, row, col): def add_line( self, - x_data, - y_data, + x, + y, layer=0, subplot: LinePlot | None = None, row: int | None = None, col: int | None = None, - plot_type="plot", **kwargs, ): if row is not None and col is not None: @@ -123,13 +365,293 @@ def add_line( subplot = self.add_subplot(col=col, row=row) subplot.add_line( - x_data=x_data, - y_data=y_data, + x=x, + y=y, layer=layer, - plot_type=plot_type, **kwargs, ) + def _get_or_create_subplot(self, row, col): + """Return the subplot at (row, col), creating it if needed.""" + if row is not None and col is not None: + try: + sp = self._subplot_matrix[row][col] + except (IndexError, KeyError): + raise ValueError("Invalid subplot position.") + else: + row, col = 0, 0 + sp = self._subplot_matrix[row][col] + if sp is None: + row, col = self.generate_new_rowcol(row, col) + sp = self.add_subplot(col=col, row=row) + return sp + + def scatter( + self, + x, + y, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """ + Add a scatter plot to the canvas (matplotlib-style convenience method). + + Parameters: + x (array-like): X-axis data. + y (array-like): Y-axis data. + layer (int): Layer index (default 0). + row, col (int): Subplot position (default top-left). + **kwargs: Forwarded to the backend (e.g., color, marker, s, label). + """ + sp = self._get_or_create_subplot(row, col) + sp.scatter(x, y, layer=layer, **kwargs) + + def bar( + self, + x, + height, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """ + Add a bar chart to the canvas (matplotlib-style convenience method). + + Parameters: + x (array-like): X positions of the bars. + height (array-like): Heights of the bars. + layer (int): Layer index (default 0). + row, col (int): Subplot position (default top-left). + **kwargs: Forwarded to the backend (e.g., color, width, label). + """ + sp = self._get_or_create_subplot(row, col) + sp.bar(x, height, 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_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_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_xlim( + self, left=None, right=None, row: int | None = None, col: int | None = None + ): + """Set the x-axis limits for a subplot (default top-left).""" + self._get_or_create_subplot(row, col).set_xlim(left, right) + + def set_ylim( + self, bottom=None, top=None, row: int | None = None, col: int | None = None + ): + """Set the y-axis limits for a subplot (default top-left).""" + self._get_or_create_subplot(row, col).set_ylim(bottom, top) + + def set_grid( + self, visible: bool = True, row: int | None = None, col: int | None = None + ): + """Show or hide the grid for a subplot (default top-left).""" + self._get_or_create_subplot(row, col).set_grid(visible) + + def set_legend( + self, visible: bool = True, row: int | None = None, col: int | None = None + ): + """Show or hide the legend for a subplot (default top-left).""" + self._get_or_create_subplot(row, col).set_legend(visible) + + 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) + + 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_xticks( + self, ticks, labels=None, row: int | None = None, col: int | None = None + ): + """Set x-axis tick positions (and optional labels) for a subplot.""" + self._get_or_create_subplot(row, col).set_xticks(ticks, labels) + + def set_yticks( + self, ticks, labels=None, row: int | None = None, col: int | None = None + ): + """Set y-axis tick positions (and optional labels) for a subplot.""" + self._get_or_create_subplot(row, col).set_yticks(ticks, labels) + + def fill_between( + self, + x, + y1, + y2=0, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Fill the region between two curves on a subplot.""" + self._get_or_create_subplot(row, col).fill_between( + x, y1, y2, layer=layer, **kwargs + ) + + def errorbar( + self, + x, + y, + yerr=None, + xerr=None, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Add an error-bar line to a subplot.""" + self._get_or_create_subplot(row, col).errorbar( + x, y, yerr=yerr, xerr=xerr, layer=layer, **kwargs + ) + + def axhline( + self, y=0, layer=0, row: int | None = None, col: int | None = None, **kwargs + ): + """Add a full-width horizontal reference line to a subplot.""" + self._get_or_create_subplot(row, col).axhline(y=y, layer=layer, **kwargs) + + def axvline( + self, x=0, layer=0, row: int | None = None, col: int | None = None, **kwargs + ): + """Add a full-height vertical reference line to a subplot.""" + self._get_or_create_subplot(row, col).axvline(x=x, layer=layer, **kwargs) + + def hlines( + self, + y, + xmin, + xmax, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Add horizontal lines at specified y positions to a subplot.""" + self._get_or_create_subplot(row, col).hlines( + y, xmin, xmax, layer=layer, **kwargs + ) + + def vlines( + self, + x, + ymin, + ymax, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Add vertical lines at specified x positions to a subplot.""" + self._get_or_create_subplot(row, col).vlines( + x, ymin, ymax, layer=layer, **kwargs + ) + + def annotate( + self, + text, + xy, + xytext=None, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Add an annotation (with optional arrow) to a subplot.""" + self._get_or_create_subplot(row, col).annotate( + text, xy, xytext=xytext, layer=layer, **kwargs + ) + + def text( + self, x, y, s, layer=0, row: int | None = None, col: int | None = None, **kwargs + ): + """Add a text label at (x, y) on a subplot.""" + self._get_or_create_subplot(row, col).text(x, y, s, layer=layer, **kwargs) + + def imshow( + self, + data, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Add an image/matrix plot to a subplot.""" + self._get_or_create_subplot(row, col).add_imshow(data, layer=layer, **kwargs) + + def add_patch( + self, + patch, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Add a Matplotlib patch to a subplot.""" + self._get_or_create_subplot(row, col).add_patch(patch, layer=layer, **kwargs) + + def colorbar( + self, + label: str = "", + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """Add a colorbar to the most recent imshow() on a subplot (matplotlib backend).""" + self._get_or_create_subplot(row, col).add_colorbar( + label=label, layer=layer, **kwargs + ) + + # ------------------------------------------------------------------ + # Multi-subplot helpers + # ------------------------------------------------------------------ + + def subplot(self, row: int = 0, col: int = 0) -> LinePlot: + """ + Return the LinePlot at position (row, col). + + Raises ValueError if no subplot has been created there yet. + """ + sp = self._subplot_matrix[row][col] + if sp is None: + raise ValueError( + f"No subplot at ({row}, {col}). " + "Call add_subplot() or use Canvas.subplots() first." + ) + return sp + + def iter_subplots(self): + """Yield (row, col, subplot) for every initialized subplot, row-major.""" + for r in range(self.nrows): + for c in range(self.ncols): + sp = self._subplot_matrix[r][c] + if sp is not None: + yield r, c, sp + + def suptitle(self, title: str, **kwargs): + """ + Set a figure-level title (rendered above all subplots). + + Parameters: + title (str): Title text. + **kwargs: Forwarded to matplotlib's fig.suptitle (e.g., fontsize, y). + """ + self._suptitle = title + self._suptitle_kwargs = kwargs + def add_tikzfigure( self, col=None, @@ -239,36 +761,96 @@ def savefig( savefig=True, layers=layers, ) - _fn = f"{filename_no_extension}_{layers}.{extension}" - fig.savefig(_fn) - print(f"Saved {_fn}") + _fn = f"{filename_no_extension}_{layers}.{extension}" + savefig_kwargs = {"dpi": self.dpi} if self.dpi is not None else {} + fig.savefig(_fn, **savefig_kwargs) + print(f"Saved {_fn}") + else: + if layers is None: + layers = self.layers + full_filepath = filename + else: + full_filepath = f"{filename_no_extension}_{layers}.{extension}" + + if self._plotted: + savefig_kwargs = {"dpi": self.dpi} if self.dpi is not None else {} + self._matplotlib_fig.savefig(full_filepath, **savefig_kwargs) + else: + fig, axs = self.plot( + backend="matplotlib", + savefig=True, + layers=layers, + ) + savefig_kwargs = {"dpi": self.dpi} if self.dpi is not None else {} + fig.savefig(full_filepath, **savefig_kwargs) + if verbose: + print(f"Saved {full_filepath}") + elif backend == "plotext": + if layer_by_layer: + layers = [] + for layer in self.layers: + layers.append(layer) + figure = self.plot( + backend="plotext", + savefig=False, + layers=layers, + ) + _fn = f"{filename_no_extension}_{layers}.{extension}" + figure.savefig(_fn) + print(f"Saved {_fn}") + else: + if layers is None: + layers = self.layers + full_filepath = filename + else: + full_filepath = f"{filename_no_extension}_{layers}.{extension}" + figure = self.plot( + backend="plotext", + savefig=False, + layers=layers, + ) + figure.savefig(full_filepath) + if verbose: + print(f"Saved {full_filepath}") + elif backend == "plotly": + if layer_by_layer: + layers = [] + for layer in self.layers: + layers.append(layer) + full_filepath = f"{filename_no_extension}_{layers}{extension}" + fig = self.plot( + backend="plotly", + savefig=False, + layers=layers, + ) + self._save_plotly(fig, full_filepath) + if verbose: + print(f"Saved {full_filepath}") else: if layers is None: layers = self.layers full_filepath = filename else: - full_filepath = f"{filename_no_extension}_{layers}.{extension}" - - if self._plotted: - self._matplotlib_fig.savefig(full_filepath) - else: - - fig, axs = self.plot( - backend="matplotlib", - savefig=True, - layers=layers, - ) - fig.savefig(full_filepath) + full_filepath = f"{filename_no_extension}_{layers}{extension}" + fig = self.plot( + backend="plotly", + savefig=False, + layers=layers, + ) + self._save_plotly(fig, full_filepath) if verbose: print(f"Saved {full_filepath}") def plot( self, backend: Backends = "matplotlib", - savefig=False, - layers=None, + savefig: bool = False, + layers: list | None = None, + usetex: bool | None = None, verbose: bool = False, ): + resolved_usetex = self._usetex if usetex is None else usetex + if verbose: print(f"Plotting figure using backend: {backend}") @@ -276,36 +858,71 @@ def plot( return self.plot_matplotlib( savefig=savefig, layers=layers, + usetex=resolved_usetex, verbose=verbose, ) elif backend == "plotly": - return self.plot_plotly(savefig=savefig) - elif backend == "tikzpics": - return self.plot_tikzpics(savefig=savefig) + return self.plot_plotly( + savefig=savefig, + layers=layers, + usetex=resolved_usetex, + verbose=verbose, + ) + elif backend == "plotext": + return self.plot_plotext( + savefig=savefig, + layers=layers, + verbose=verbose, + ) + elif backend == "tikzfigure": + return self.plot_tikzfigure(savefig=savefig, verbose=verbose) else: raise ValueError(f"Invalid backend: {backend}") def show( self, backend: Backends = "matplotlib", + layers: list | None = None, + usetex: bool | None = None, verbose: bool = False, ): if verbose: - print(f"Showing figure using backend: {backend}") + print(f"Showing canvas using backend: {backend}") if backend == "matplotlib": - self.plot( + if verbose: + print("Generating Matplotlib figure for display...") + fig, axes = self.plot( backend="matplotlib", savefig=False, - layers=None, + layers=layers, + usetex=usetex, verbose=verbose, ) - # self._matplotlib_fig.show() + if verbose: + print("Displaying Matplotlib figure...") + plt.show() + return fig, axes elif backend == "plotly": - self.plot_plotly(savefig=False) - elif backend == "tikzpics": - fig = self.plot_tikzpics(savefig=False) + resolved_usetex = self._usetex if usetex is None else usetex + fig = self.plot_plotly( + savefig=False, layers=layers, usetex=resolved_usetex, verbose=verbose + ) fig.show() + return fig + elif backend == "plotext": + figure = self.plot_plotext( + savefig=False, + layers=layers, + verbose=verbose, + ) + figure.show() + return figure + elif backend == "tikzfigure": + fig = self.plot_tikzfigure(savefig=False, verbose=verbose) + # TikzFigure handles all rendering (single or multi-subplot) + fig.show(transparent=False) + return fig else: raise ValueError("Invalid backend") @@ -313,7 +930,7 @@ def plot_matplotlib( self, savefig: bool = False, layers: list | None = None, - usetex: bool = False, + usetex: bool | None = None, verbose: bool = False, ): """ @@ -325,7 +942,9 @@ def plot_matplotlib( if verbose: print("Generating Matplotlib figure...") - tex_fonts = setup_tex_fonts(fontsize=self.fontsize, usetex=usetex) + resolved_usetex = self._usetex if usetex is None else usetex + tex_fonts = setup_tex_fonts(fontsize=self.fontsize, usetex=resolved_usetex) + render_dpi = self.dpi if savefig else None setup_plotstyle( tex_fonts=tex_fonts, @@ -337,27 +956,41 @@ def plot_matplotlib( if verbose: print("Plot style set up.") print(f"{self._figsize = } {self._width = } {self._ratio = }") + subplot_kwargs = { + "squeeze": False, + "gridspec_kw": self._gridspec_kw, + } if self._figsize is not None: - fig_width, fig_height = self._figsize - else: + subplot_kwargs["figsize"] = self._figsize + elif self._width is not None: fig_width, fig_height = set_size( width=self._width, ratio=self._ratio, - dpi=self.dpi, + dpi=render_dpi, verbose=verbose, ) + subplot_kwargs["figsize"] = (fig_width, fig_height) if verbose: - print(f"Figure size: {fig_width} x {fig_height} points") + if "figsize" in subplot_kwargs: + fig_width, fig_height = subplot_kwargs["figsize"] + print(f"Figure size: {fig_width} x {fig_height} inches") + else: + print("Figure size: Matplotlib default") + print(f"Render DPI override: {render_dpi} (export DPI: {self.dpi})") + + if render_dpi is not None: + subplot_kwargs["dpi"] = render_dpi fig, axes = plt.subplots( self.nrows, self.ncols, - figsize=(fig_width, fig_height), - squeeze=False, - dpi=self.dpi, + **subplot_kwargs, ) - for (row, col), subplot in self.subplots.items(): + if verbose: + print(f"Created Matplotlib figure and axes with shape {axes.shape}") + + for (row, col), subplot in self._subplot_dict.items(): ax = axes[row][col] if isinstance(subplot, TikzFigure): plot_matplotlib(subplot, ax, layers=layers) @@ -366,83 +999,348 @@ def plot_matplotlib( # ax.set_title(f"Subplot ({row}, {col})") ax.grid() + if verbose: + print("Finished plotting subplots.") + + if self._suptitle: + suptitle_kwargs = dict(self._suptitle_kwargs) + suptitle_kwargs.setdefault("fontsize", self.fontsize) + fig.suptitle(self._suptitle, **suptitle_kwargs) + + if verbose: + print("Set suptitle.") + # Set caption, labels, etc., if needed self._plotted = True self._matplotlib_fig = fig self._matplotlib_axes = axes return fig, axes - def plot_tikzpics( + def plot_tikzfigure( self, - savefig=None, - verbose=False, + savefig: bool = False, + verbose: bool = False, ) -> TikzFigure: - if len(self.subplots) > 1: + """ + Generate a TikZ figure from subplots. + + For now, returns the first subplot's TikzFigure. + Full multi-subplot support requires TikzFigure's subfigure_axis API. + + Parameters: + verbose (bool): If True, print debug information. + + Returns: + TikzFigure: Figure object that can be shown, saved, or compiled. + """ + if verbose: + print(f"Plotting tikzfigure with {len(self._subplot_dict)} subplot(s)") + + # Check for unsupported layouts + if self.nrows > 1: raise NotImplementedError( - "Only one subplot is supported for tikzpics backend." + "Vertical/grid layouts (nrows > 1) are not yet supported for tikzfigure backend. " + "Use horizontal layouts (1×n) only." + ) + + # Validate that at least one subplot exists + if len(self._subplot_dict) == 0: + raise ValueError( + "No subplots to plot. Call add_subplot() or Canvas.subplots() first." ) - for (row, col), line_plot in self.subplots.items(): + + axis_width, axis_height = self._get_tikzfigure_axis_dimensions() + fig = TikzFigure() + + # Add each subplot as a subfigure axis + for (row, col), line_plot in self._subplot_dict.items(): if verbose: print(f"Plotting subplot at row {row}, col {col}") - print(f"{line_plot = }") - tikz_subplot = line_plot.plot_tikzpics(verbose=verbose) - return tikz_subplot - def plot_plotly(self, show=True, savefig=None, usetex=False): + # Create subfigure axis with subplot metadata + ax = fig.subfigure_axis( + xlabel=line_plot._xlabel or "", + ylabel=line_plot._ylabel or "", + xlim=( + (line_plot._xmin, line_plot._xmax) + if line_plot._xmin is not None + else None + ), + ylim=( + (line_plot._ymin, line_plot._ymax) + if line_plot._ymin is not None + else None + ), + grid=line_plot._grid, + title=line_plot._title or f"Subplot {col + 1}", + width=0.45, + axis_width=axis_width, + height=axis_height, + ) + + # Add each plot line to the subfigure + for line_data in line_plot.line_data: + if line_data.get("plot_type") == "plot": + # Extract and transform x, y data + x = (line_data["x"] + line_plot._xshift) * line_plot._xscale + y = (line_data["y"] + line_plot._yshift) * line_plot._yscale + kwargs = line_data.get("kwargs", {}) + if verbose: + print(f"Line {kwargs = }") + # Add plot to subfigure axis + ax.add_plot( + x=x, + y=y, + # label=kwargs.get("label", ""), + color=kwargs.get("color", "black"), + line_width=kwargs.get("linewidth", 1.0), + ) + + # Add legend if requested + if line_plot._legend and len(line_plot.line_data) > 0: + ax.set_legend(position="north east") + + return fig + + def _get_tikzfigure_axis_dimensions(self) -> tuple[str | None, str | None]: + if self._width is None: + return None, None + + total_width_in, total_height_in = set_size( + width=self._width, + ratio=self._ratio, + dpi=self._dpi if self._dpi is not None else 300, + ) + total_width_cm = total_width_in * 2.54 + total_height_cm = total_height_in * 2.54 + horizontal_sep_cm = getattr(TikzFigure, "GROUPPLOT_HORIZONTAL_SEP_CM", 1.5) + available_width_cm = total_width_cm - horizontal_sep_cm * (self.ncols - 1) + if available_width_cm <= 0: + raise ValueError( + f'Canvas width "{self._width}" is too small for {self.ncols} ' + "tikzfigure subplot(s)." + ) + + axis_width_cm = available_width_cm / self.ncols + return f"{axis_width_cm:.6g}cm", f"{total_height_cm:.6g}cm" + + def plot_plotext( + self, + savefig: bool = False, + layers: list | None = None, + verbose: bool = False, + ) -> PlotextFigure: + if verbose: + print("Generating plotext figure...") + + figure = create_plotext_figure(self.nrows, self.ncols) + + for row, col, subplot in self.iter_subplots(): + ax = ( + figure + if (self.nrows, self.ncols) == (1, 1) + else figure.subplot(row + 1, col + 1) + ) + if isinstance(subplot, TikzFigure): + raise NotImplementedError( + "tikzfigure subplots cannot be rendered with the plotext backend." + ) + subplot.plot_plotext(ax, layers=layers) + + wrapped = PlotextFigure(figure=figure, suptitle=self._suptitle) + if savefig and isinstance(savefig, str): + wrapped.savefig(savefig) + + self._plotext_figure = wrapped + return wrapped + + def plot_plotly( + self, + show=True, + savefig=None, + layers: list | None = None, + usetex: bool | None = None, + verbose: bool = False, + ): """ Generate and optionally display the subplots using Plotly. Parameters: show (bool): Whether to display the plot. savefig (str, optional): Filename to save the figure if provided. + verbose (bool): Whether to print verbose output. + """ + resolved_usetex = self._usetex if usetex is None else usetex + setup_tex_fonts( fontsize=self.fontsize, - usetex=usetex, + usetex=resolved_usetex, ) # adjust or redefine for Plotly if needed - # Set default width and height if not specified - if self._figsize is not None: - fig_width, fig_height = self._figsize - else: - fig_width, fig_height = set_size( - width=self._width, - ratio=self._ratio, - ) - # print(self._width, fig_width, fig_height) - # Create subplots + # Create subplot titles in row-major order (Plotly expects rows*cols entries) + subplot_titles = [""] * (self.nrows * self.ncols) + for (row, col), sp in self._subplot_dict.items(): + index = row * self.ncols + col + subplot_titles[index] = sp._title or f"({row}, {col})" + fig = make_subplots( rows=self.nrows, cols=self.ncols, - subplot_titles=[ - f"Subplot ({row}, {col})" for (row, col) in self.subplots.keys() - ], + subplot_titles=subplot_titles, ) - # Plot each subplot - for (row, col), line_plot in self.subplots.items(): - traces = line_plot.plot_plotly() # Generate Plotly traces for the line_plot + # 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) for trace in traces: 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}" + + for shape in shapes: + shape = dict(shape) + if shape.get("xref") not in {"paper"}: + shape["xref"] = xref + if shape.get("yref") not in {"paper"}: + shape["yref"] = yref + fig.add_shape(shape) + + for annotation in annotations: + annotation = dict(annotation) + annotation.setdefault("xref", xref) + annotation.setdefault("yref", yref) + fig.add_annotation(annotation) + + # Apply per-axis config in a row/col-safe way + xaxis_kwargs = dict( + title_text=line_plot._xlabel or None, + showgrid=bool(line_plot._grid), + row=row + 1, + col=col + 1, + ) + if line_plot._xaxis_scale == "log": + xaxis_kwargs["type"] = "log" + fig.update_xaxes(**xaxis_kwargs) + + yaxis_kwargs = dict( + title_text=line_plot._ylabel or None, + showgrid=bool(line_plot._grid), + row=row + 1, + col=col + 1, + ) + if line_plot._yaxis_scale == "log": + yaxis_kwargs["type"] = "log" + fig.update_yaxes(**yaxis_kwargs) + + # Axis limits + if line_plot._xmin is not None or line_plot._xmax is not None: + x_range = [line_plot._xmin, line_plot._xmax] + if x_range[0] is not None: + x_range[0] = line_plot._transform_scalar_x(x_range[0]) + if x_range[1] is not None: + x_range[1] = line_plot._transform_scalar_x(x_range[1]) + if ( + line_plot._xaxis_scale == "log" + and x_range[0] is not None + and x_range[1] is not None + and x_range[0] > 0 + and x_range[1] > 0 + ): + x_range = [np.log10(x_range[0]), np.log10(x_range[1])] + fig.update_xaxes( + range=x_range, + row=row + 1, + col=col + 1, + ) + if line_plot._ymin is not None or line_plot._ymax is not None: + y_range = [line_plot._ymin, line_plot._ymax] + if y_range[0] is not None: + y_range[0] = line_plot._transform_scalar_y(y_range[0]) + if y_range[1] is not None: + y_range[1] = line_plot._transform_scalar_y(y_range[1]) + if ( + line_plot._yaxis_scale == "log" + and y_range[0] is not None + and y_range[1] is not None + and y_range[0] > 0 + and y_range[1] > 0 + ): + y_range = [np.log10(y_range[0]), np.log10(y_range[1])] + fig.update_yaxes( + range=y_range, + row=row + 1, + col=col + 1, + ) + + # Custom ticks (positions + optional labels) + if line_plot._xticks is not None: + tickvals = [line_plot._transform_scalar_x(v) for v in line_plot._xticks] + fig.update_xaxes( + tickmode="array", + tickvals=tickvals, + ticktext=line_plot._xticklabels, + row=row + 1, + col=col + 1, + ) + if line_plot._yticks is not None: + tickvals = [line_plot._transform_scalar_y(v) for v in line_plot._yticks] + fig.update_yaxes( + tickmode="array", + tickvals=tickvals, + ticktext=line_plot._yticklabels, + row=row + 1, + col=col + 1, + ) + + # Aspect ratio + if line_plot._aspect == "equal": + fig.update_yaxes(scaleanchor=xref, row=row + 1, col=col + 1) + elif isinstance(line_plot._aspect, (int, float)): + fig.update_yaxes( + scaleanchor=xref, + scaleratio=float(line_plot._aspect), + row=row + 1, + col=col + 1, + ) + # Update layout settings fig.update_layout( - # width=fig_width, - # height=fig_height, font=dict(size=self.fontsize), - margin=dict(l=10, r=10, t=40, b=10), # Adjust margins if needed + margin=dict(l=10, r=10, t=40, b=10), ) + if self._suptitle: + fig.update_layout(title=dict(text=self._suptitle, x=0.5)) - # Optionally save the figure if savefig: - fig.write_image(savefig) + try: + fig.write_image(savefig) + except Exception as exc: + raise RuntimeError( + "Plotly image export failed. If you are exporting to PNG/PDF/SVG, " + "install kaleido (e.g., `pip install -U kaleido`)." + ) from exc - # Show or return the figure - # if show: - # fig.show() return fig + def _save_plotly(self, fig, filename: str) -> None: + _, extension = os.path.splitext(filename) + extension = extension.lower() + if extension in {".html", ".htm"}: + fig.write_html(filename) + return + try: + fig.write_image(filename) + except Exception as exc: + raise RuntimeError( + "Plotly image export failed. For PNG/PDF/SVG export, install kaleido " + "(e.g., `pip install -U kaleido`), or export to HTML instead." + ) from exc + # Property getters @property @@ -477,6 +1375,10 @@ def label(self): def figsize(self): return self._figsize + @property + def usetex(self): + return self._usetex + @property def subplot_matrix(self): return self._subplot_matrix @@ -507,13 +1409,6 @@ def label(self, value): def figsize(self, value): self._figsize = value - # Magic methods - def __str__(self): - return f"Canvas(nrows={self.nrows}, ncols={self.ncols}, figsize={self.figsize})" - - def __repr__(self): - return f"Canvas(nrows={self.nrows}, ncols={self.ncols}, caption={self.caption}, label={self.label})" - def __getitem__(self, key): """Allows accessing subplots by tuple index.""" row, col = key @@ -528,145 +1423,17 @@ def __setitem__(self, key, value): raise IndexError("Subplot index out of range") self._subplot_matrix[row][col] = value + def __repr__(self): + return f"Canvas(nrows={self.nrows}, ncols={self.ncols}, caption={self.caption}, label={self.label})" -def plot_matplotlib(tikzfigure: TikzFigure, ax, layers=None): - """ - Plot all nodes and paths on the provided axis using Matplotlib. - - Parameters: - - ax (matplotlib.axes.Axes): Axis on which to plot the figure. - """ - - # TODO: Specify which layers to retreive nodes from with layers=layers - nodes = tikzfigure.layers.get_nodes() - paths = tikzfigure.layers.get_paths() - - for path in paths: - x_coords = [node.x for node in path.nodes] - y_coords = [node.y for node in path.nodes] - - # Parse path color - path_color_spec = path.kwargs.get("color", "black") - try: - color = Color(path_color_spec).to_rgb() - except ValueError as e: - print(e) - color = "black" - - # Parse line width - line_width_spec = path.kwargs.get("line_width", 1) - if isinstance(line_width_spec, str): - match = re.match(r"([\d.]+)(pt)?", line_width_spec) - if match: - line_width = float(match.group(1)) - else: - print( - f"Invalid line width specification: '{line_width_spec}', defaulting to 1", - ) - line_width = 1 - else: - line_width = float(line_width_spec) - - # Parse line style using Linestyle class - style_spec = path.kwargs.get("style", "solid") - linestyle = Linestyle(style_spec).to_matplotlib() - - ax.plot( - x_coords, - y_coords, - color=color, - linewidth=line_width, - linestyle=linestyle, - zorder=1, # Lower z-order to place behind nodes - ) - - # Plot nodes after paths so they appear on top - for node in nodes: - # Determine shape and size - shape = node.kwargs.get("shape", "circle") - fill_color_spec = node.kwargs.get("fill", "white") - edge_color_spec = node.kwargs.get("draw", "black") - linewidth = float(node.kwargs.get("line_width", 1)) - size = float(node.kwargs.get("size", 1)) - - # Parse colors using the Color class - try: - facecolor = Color(fill_color_spec).to_rgb() - except ValueError as e: - print(e) - facecolor = "white" - - try: - edgecolor = Color(edge_color_spec).to_rgb() - except ValueError as e: - print(e) - edgecolor = "black" - - # Plot shapes - if shape == "circle": - radius = size / 2 - circle = patches.Circle( - (node.x, node.y), - radius, - facecolor=facecolor, - edgecolor=edgecolor, - linewidth=linewidth, - zorder=2, # Higher z-order to place on top of paths - ) - ax.add_patch(circle) - elif shape == "rectangle": - width = height = size - rect = patches.Rectangle( - (node.x - width / 2, node.y - height / 2), - width, - height, - facecolor=facecolor, - edgecolor=edgecolor, - linewidth=linewidth, - zorder=2, # Higher z-order - ) - ax.add_patch(rect) - else: - # Default to circle if shape is unknown - radius = size / 2 - circle = patches.Circle( - (node.x, node.y), - radius, - facecolor=facecolor, - edgecolor=edgecolor, - linewidth=linewidth, - zorder=2, - ) - ax.add_patch(circle) - - # Add text inside the shape - if node.content: - ax.text( - node.x, - node.y, - node.content, - fontsize=10, - ha="center", - va="center", - wrap=True, - zorder=3, # Even higher z-order for text - ) - - # Remove axes, ticks, and legend - ax.axis("off") - - # Adjust plot limits - all_x = [node.x for node in nodes] - all_y = [node.y for node in nodes] - padding = 1 # Adjust padding as needed - ax.set_xlim(min(all_x) - padding, max(all_x) + padding) - ax.set_ylim(min(all_y) - padding, max(all_y) + padding) - ax.set_aspect("equal", adjustable="datalim") + # Magic methods + def __str__(self): + return f"Canvas(nrows={self.nrows}, ncols={self.ncols}, figsize={self.figsize})" if __name__ == "__main__": c = Canvas(ncols=2, nrows=2) sp = c.add_subplot() - sp.add_line("Line 1", [0, 1, 2, 3], [0, 1, 4, 9]) - c.plot() + sp.plot([0, 1, 2, 3], [0, 1, 4, 9], label="Line 1") + c.plot(backend="matplotlib") print("done") diff --git a/src/maxplotlib/colors/colors.py b/src/maxplotlib/colors/colors.py index fdb117e..965e54c 100644 --- a/src/maxplotlib/colors/colors.py +++ b/src/maxplotlib/colors/colors.py @@ -5,17 +5,6 @@ class Color: - def __init__(self, color_spec): - """ - Initialize the Color object by parsing the color specification. - - Parameters: - - color_spec: Can be a TikZ color string (e.g., 'blue!20'), a standard color name, - an RGB tuple, a hex code, etc. - """ - self.color_spec = color_spec - self.rgb = self._parse_color(color_spec) - def _parse_color(self, color_spec): """ Internal method to parse the color specification and convert it to an RGB tuple. @@ -53,6 +42,17 @@ def _parse_color(self, color_spec): except ValueError: raise ValueError(f"Invalid color specification: '{color_spec}'") + def __init__(self, color_spec): + """ + Initialize the Color object by parsing the color specification. + + Parameters: + - color_spec: Can be a TikZ color string (e.g., 'blue!20'), a standard color name, + an RGB tuple, a hex code, etc. + """ + self.color_spec = color_spec + self.rgb = self._parse_color(color_spec) + def to_rgb(self): """ Return the color as an RGB tuple. diff --git a/src/maxplotlib/linestyle/linestyle.py b/src/maxplotlib/linestyle/linestyle.py index 0ba1f04..c5a0625 100644 --- a/src/maxplotlib/linestyle/linestyle.py +++ b/src/maxplotlib/linestyle/linestyle.py @@ -2,17 +2,6 @@ class Linestyle: - def __init__(self, style_spec): - """ - Initialize the Linestyle object by parsing the style specification. - - Parameters: - - style_spec: Can be a TikZ-style line style string (e.g., 'dashed', 'dotted', 'solid', 'dashdot'), - or a custom dash pattern. - """ - self.style_spec = style_spec - self.matplotlib_style = self._parse_style(style_spec) - def _parse_style(self, style_spec): """ Internal method to parse the style specification and convert it to a Matplotlib linestyle. @@ -48,6 +37,17 @@ def _parse_style(self, style_spec): print(f"Unknown line style: '{style_spec}', defaulting to 'solid'") return "solid" + def __init__(self, style_spec): + """ + Initialize the Linestyle object by parsing the style specification. + + Parameters: + - style_spec: Can be a TikZ-style line style string (e.g., 'dashed', 'dotted', 'solid', 'dashdot'), + or a custom dash pattern. + """ + self.style_spec = style_spec + self.matplotlib_style = self._parse_style(style_spec) + def to_matplotlib(self): """ Return the line style in Matplotlib format. diff --git a/src/maxplotlib/subfigure/line_plot.py b/src/maxplotlib/subfigure/line_plot.py index 8a50cfc..44779d4 100644 --- a/src/maxplotlib/subfigure/line_plot.py +++ b/src/maxplotlib/subfigure/line_plot.py @@ -2,7 +2,7 @@ import numpy as np import plotly.graph_objects as go from mpl_toolkits.axes_grid1 import make_axes_locatable -from tikzpics import TikzFigure +from tikzfigure import TikzFigure class Node: @@ -67,6 +67,7 @@ def __init__( """ self._title = title + self._caption = None self._grid = grid self._legend = legend self._xmin = xmin @@ -80,6 +81,19 @@ def __init__( self._xshift = xshift self._yshift = yshift + # Axis scale type ('linear', 'log', 'symlog') + self._xaxis_scale: str | None = None + self._yaxis_scale: str | None = None + + # Custom tick positions and labels + self._xticks: list | None = None + self._xticklabels: list | None = None + self._yticks: list | None = None + self._yticklabels: list | None = None + + # Aspect ratio + self._aspect = None + # List to store line data, each entry contains x and y data, label, and plot kwargs self.line_data = [] self.layered_line_data = {} @@ -103,53 +117,306 @@ def _add(self, obj, layer): def add_line( self, - x_data, - y_data, + x, + y, layer=0, - plot_type="plot", **kwargs, ): """ - Add a line to the plot. + Add a line to the subplot. + + Parameters: + x (array-like): X-axis data. + y (array-like): Y-axis data. + layer (int): Layer index (default 0). + **kwargs: Additional keyword arguments forwarded to the backend + (e.g., color, linestyle, label, linewidth). + """ + ld = { + "x": np.array(x), + "y": np.array(y), + "layer": layer, + "plot_type": "plot", + "kwargs": kwargs, + } + self._add(ld, layer) + + def plot(self, x, y, layer=0, **kwargs): + """Matplotlib-style alias for :meth:`add_line`.""" + self.add_line(x, y, layer=layer, **kwargs) + + def scatter(self, x, y, layer=0, **kwargs): + """ + Add a scatter plot to the subplot. + + Parameters: + x (array-like): X-axis data. + y (array-like): Y-axis data. + layer (int): Layer index (default 0). + **kwargs: Additional keyword arguments forwarded to the backend + (e.g., color, marker, s, label). + """ + ld = { + "x": np.array(x), + "y": np.array(y), + "layer": layer, + "plot_type": "scatter", + "kwargs": kwargs, + } + self._add(ld, layer) + + def bar(self, x, height, layer=0, **kwargs): + """ + Add a bar chart to the subplot. + + Parameters: + x (array-like): X positions of the bars. + height (array-like): Heights of the bars. + layer (int): Layer index (default 0). + **kwargs: Additional keyword arguments forwarded to the backend + (e.g., color, width, label). + """ + ld = { + "x": np.array(x), + "height": np.array(height), + "layer": layer, + "plot_type": "bar", + "kwargs": kwargs, + } + self._add(ld, layer) + + def axhline(self, y=0, layer=0, **kwargs): + """ + Add a horizontal line spanning the full width of the axes. + + Parameters: + y (float): Y-coordinate of the line (default 0). + **kwargs: Additional keyword arguments (e.g., color, linestyle, label). + """ + ld = { + "y": y, + "layer": layer, + "plot_type": "axhline", + "kwargs": kwargs, + } + self._add(ld, layer) + + def axvline(self, x=0, layer=0, **kwargs): + """ + Add a vertical line spanning the full height of the axes. + + Parameters: + x (float): X-coordinate of the line (default 0). + **kwargs: Additional keyword arguments (e.g., color, linestyle, label). + """ + ld = { + "x": x, + "layer": layer, + "plot_type": "axvline", + "kwargs": kwargs, + } + self._add(ld, layer) + + def set_xlabel(self, label: str): + """Set the x-axis label.""" + self._xlabel = label + + def set_ylabel(self, label: str): + """Set the y-axis label.""" + self._ylabel = label + + def set_title(self, title: str): + """Set the subplot title.""" + self._title = title + + def set_xlim(self, left=None, right=None): + """Set the x-axis limits.""" + if left is not None: + self._xmin = left + if right is not None: + self._xmax = right + + def set_ylim(self, bottom=None, top=None): + """Set the y-axis limits.""" + if bottom is not None: + self._ymin = bottom + if top is not None: + self._ymax = top + + def set_grid(self, visible: bool = True): + """Show or hide the grid.""" + self._grid = visible + + def set_legend(self, visible: bool = True): + """Show or hide the legend.""" + self._legend = visible + + def set_xscale(self, scale: str): + """Set the x-axis scale type: 'linear', 'log', or 'symlog'.""" + self._xaxis_scale = scale + + 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.""" + self._xticks = list(ticks) + self._xticklabels = list(labels) if labels is not None else None + + def set_yticks(self, ticks, labels=None): + """Set y-axis tick positions and optional labels.""" + self._yticks = list(ticks) + self._yticklabels = list(labels) if labels is not None else None + + def set_aspect(self, aspect): + """Set the axes aspect ratio: 'equal', 'auto', or a float.""" + self._aspect = aspect + + def fill_between(self, x, y1, y2=0, layer=0, **kwargs): + """ + Fill the region between two curves. + + Parameters: + x (array-like): X-axis data. + y1 (array-like): Upper boundary. + y2 (array-like or scalar): Lower boundary (default 0). + layer (int): Layer index. + **kwargs: Forwarded to the backend (e.g., color, alpha, label). + """ + ld = { + "x": np.array(x), + "y1": np.array(y1) if not np.isscalar(y1) else y1, + "y2": np.array(y2) if not np.isscalar(y2) else y2, + "layer": layer, + "plot_type": "fill_between", + "kwargs": kwargs, + } + self._add(ld, layer) + + def errorbar(self, x, y, yerr=None, xerr=None, layer=0, **kwargs): + """ + Add a line plot with error bars. + + Parameters: + x (array-like): X-axis data. + y (array-like): Y-axis data. + yerr (array-like or scalar, optional): Y-axis error. + xerr (array-like or scalar, optional): X-axis error. + layer (int): Layer index. + **kwargs: Forwarded to the backend (e.g., color, fmt, capsize, label). + """ + ld = { + "x": np.array(x), + "y": np.array(y), + "yerr": yerr, + "xerr": xerr, + "layer": layer, + "plot_type": "errorbar", + "kwargs": kwargs, + } + self._add(ld, layer) + + def hlines(self, y, xmin, xmax, layer=0, **kwargs): + """ + Draw horizontal lines at each y from xmin to xmax. + + Parameters: + y (float or array-like): Y positions. + xmin, xmax (float or array-like): Start and end of each line. + **kwargs: Forwarded to the backend (e.g., colors, linestyles, label). + """ + ld = { + "y": y, + "xmin": xmin, + "xmax": xmax, + "layer": layer, + "plot_type": "hlines", + "kwargs": kwargs, + } + self._add(ld, layer) + + def vlines(self, x, ymin, ymax, layer=0, **kwargs): + """ + Draw vertical lines at each x from ymin to ymax. + + Parameters: + x (float or array-like): X positions. + ymin, ymax (float or array-like): Start and end of each line. + **kwargs: Forwarded to the backend (e.g., colors, linestyles, label). + """ + ld = { + "x": x, + "ymin": ymin, + "ymax": ymax, + "layer": layer, + "plot_type": "vlines", + "kwargs": kwargs, + } + self._add(ld, layer) + + def annotate(self, text, xy, xytext=None, layer=0, **kwargs): + """ + Add a text annotation, optionally with an arrow. Parameters: - label (str): Label for the line. - x_data (list): X-axis data. - y_data (list): Y-axis data. - **kwargs: Additional keyword arguments for the plot (e.g., color, linestyle). + text (str): Annotation text. + xy (tuple): (x, y) position to annotate. + xytext (tuple, optional): (x, y) position for the text. + **kwargs: Forwarded to ax.annotate (e.g., arrowprops, fontsize, color). """ ld = { - "x": np.array(x_data), - "y": np.array(y_data), + "text": text, + "xy": xy, + "xytext": xytext, "layer": layer, - "plot_type": plot_type, + "plot_type": "annotate", "kwargs": kwargs, } self._add(ld, layer) - def add_imshow(self, data, layer=0, plot_type="imshow", **kwargs): + def text(self, x, y, s, layer=0, **kwargs): + """ + Add a text label at position (x, y). + + Parameters: + x, y (float): Position. + s (str): Text string. + **kwargs: Forwarded to ax.text (e.g., fontsize, color, ha, va). + """ + ld = { + "x": x, + "y": y, + "s": s, + "layer": layer, + "plot_type": "text", + "kwargs": kwargs, + } + self._add(ld, layer) + + def add_imshow(self, data, layer=0, **kwargs): ld = { "data": np.array(data), "layer": layer, - "plot_type": plot_type, + "plot_type": "imshow", "kwargs": kwargs, } self._add(ld, layer) - def add_patch(self, patch, layer=0, plot_type="patch", **kwargs): + def add_patch(self, patch, layer=0, **kwargs): ld = { "patch": patch, "layer": layer, - "plot_type": plot_type, + "plot_type": "patch", "kwargs": kwargs, } self._add(ld, layer) - def add_colorbar(self, label="", layer=0, plot_type="colorbar", **kwargs): + def add_colorbar(self, label="", layer=0, **kwargs): cb = { "label": label, "layer": layer, - "plot_type": plot_type, + "plot_type": "colorbar", "kwargs": kwargs, } self._add(cb, layer) @@ -173,6 +440,7 @@ def plot_matplotlib( Parameters: ax (matplotlib.axes.Axes): Axis on which to plot the lines. """ + im = None for layer_name, layer_lines in self.layered_line_data.items(): if layers and layer_name not in layers: continue @@ -189,6 +457,48 @@ def plot_matplotlib( (line["y"] + self._yshift) * self._yscale, **line["kwargs"], ) + elif line["plot_type"] == "bar": + ax.bar( + (line["x"] + self._xshift) * self._xscale, + line["height"] * self._yscale, + **line["kwargs"], + ) + elif line["plot_type"] == "fill_between": + ax.fill_between( + (line["x"] + self._xshift) * self._xscale, + ( + line["y1"] + if np.isscalar(line["y1"]) + else (line["y1"] + self._yshift) * self._yscale + ), + ( + line["y2"] + if np.isscalar(line["y2"]) + else (line["y2"] + self._yshift) * self._yscale + ), + **line["kwargs"], + ) + elif line["plot_type"] == "errorbar": + ax.errorbar( + (line["x"] + self._xshift) * self._xscale, + (line["y"] + self._yshift) * self._yscale, + yerr=line["yerr"], + xerr=line["xerr"], + **line["kwargs"], + ) + elif line["plot_type"] == "hlines": + 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"] == "annotate": + ann_kwargs = dict(line["kwargs"]) + if line["xytext"] is not None: + ann_kwargs["xytext"] = line["xytext"] + ax.annotate(line["text"], xy=line["xy"], **ann_kwargs) + elif line["plot_type"] == "text": + ax.text(line["x"], line["y"], line["s"], **line["kwargs"]) + elif line["plot_type"] == "axvline": + ax.axvline(x=line["x"], **line["kwargs"]) elif line["plot_type"] == "imshow": im = ax.imshow( line["data"], @@ -203,26 +513,41 @@ def plot_matplotlib( divider = make_axes_locatable(ax) cax = divider.append_axes("right", size="5%", pad=0.05) plt.colorbar(im, cax=cax, label="Potential (V)") - if self._title: - ax.set_title(self._title) - if self._xlabel: - ax.set_xlabel(self._xlabel) - if self._ylabel: - ax.set_ylabel(self._ylabel) - if self._legend and len(self.line_data) > 0: - ax.legend() - if self._grid: - ax.grid() - if self.xmin is not None: - ax.axis(xmin=self.xmin) - if self.xmax is not None: - ax.axis(xmax=self.xmax) - if self.ymin is not None: - ax.axis(ymin=self.ymin) - if self.ymax is not None: - ax.axis(ymax=self.ymax) - - def plot_tikzpics(self, layers=None, verbose: bool = False) -> TikzFigure: + + if self._title: + ax.set_title(self._title) + if self._xlabel: + ax.set_xlabel(self._xlabel) + if self._ylabel: + ax.set_ylabel(self._ylabel) + if self._legend and len(self.line_data) > 0: + ax.legend() + if self._grid: + ax.grid() + if self.xmin is not None: + ax.axis(xmin=self.xmin) + if self.xmax is not None: + ax.axis(xmax=self.xmax) + if self.ymin is not None: + ax.axis(ymin=self.ymin) + if self.ymax is not None: + ax.axis(ymax=self.ymax) + if self._xaxis_scale is not None: + ax.set_xscale(self._xaxis_scale) + 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) + if self._yticks is not None: + ax.set_yticks(self._yticks) + if self._yticklabels is not None: + ax.set_yticklabels(self._yticklabels) + if self._aspect is not None: + ax.set_aspect(self._aspect) + + def plot_tikzfigure(self, layers=None, verbose: bool = False) -> TikzFigure: tikz_figure = TikzFigure() for layer_name, layer_lines in self.layered_line_data.items(): @@ -235,13 +560,20 @@ def plot_tikzpics(self, layers=None, verbose: bool = False) -> TikzFigure: nodes = [[xi, yi] for xi, yi in zip(x, y)] tikz_figure.draw(nodes=nodes, **line["kwargs"]) + if verbose: + print("Generated TikZ figure:") + print(tikz_figure.generate_tikz()) return tikz_figure - def plot_plotly(self): + def plot_plotly(self, layers=None): """ - Plot all lines using Plotly and return a list of traces for each line. + Plot all lines using Plotly. + + Returns a tuple of (traces, shapes, annotations) where: + - traces are plotly graph objects to add with fig.add_trace() + - shapes are layout shape dicts to add with fig.add_shape() + - annotations are layout annotation dicts to add with fig.add_annotation() """ - # Mapping Matplotlib linestyles to Plotly dash styles linestyle_map = { "solid": "solid", "dashed": "dash", @@ -249,24 +581,1092 @@ def plot_plotly(self): "dashdot": "dashdot", } - traces = [] - for line in self.line_data: - trace = go.Scatter( - x=(line["x"] + self._xshift) * self._xscale, - y=(line["y"] + self._yshift) * self._yscale, - mode="lines+markers" if "marker" in line["kwargs"] else "lines", - name=line["kwargs"].get("label", ""), - line=dict( - color=line["kwargs"].get("color", None), - dash=linestyle_map.get( - line["kwargs"].get("linestyle", "solid"), - "solid", + marker_map = { + "o": "circle", + ".": "circle", + "s": "square", + "^": "triangle-up", + "v": "triangle-down", + "<": "triangle-left", + ">": "triangle-right", + "x": "x", + "+": "cross", + "*": "star", + "D": "diamond", + } + + traces: list[go.BaseTraceType] = [] + shapes: list[dict] = [] + annotations: list[dict] = [] + last_heatmap_idx: int | None = None + # Plotly shapes (unlike traces) don't participate in axis autorange, + # so patches would otherwise be clipped or invisible unless the caller + # sets explicit axis limits. Track each patch's bounding box here and + # add one invisible marker trace at the end so autorange sees them. + patch_bounds_x: list[float] = [] + patch_bounds_y: list[float] = [] + + def tx(values): + return self._transform_x(values) + + def ty(values): + return self._transform_y(values) + + def txs(value): + return self._transform_scalar_x(value) + + def tys(value): + return self._transform_scalar_y(value) + + def plotly_color(value): + if value is None: + return None + if isinstance(value, np.generic): + value = value.item() + if isinstance(value, (list, tuple, np.ndarray)): + arr = np.asarray(value).astype(float).reshape(-1) + if arr.size in (3, 4): + rgb = (arr[:3] * 255.0) if np.all(arr[:3] <= 1.0) else arr[:3] + r, g, b = [int(round(float(x))) for x in rgb] + if arr.size == 4: + a = float(arr[3]) + if a > 1.0: + a = a / 255.0 + return f"rgba({r},{g},{b},{a})" + return f"rgb({r},{g},{b})" + return value + + for line in self._iter_layer_lines(layers=layers): + plot_type = line["plot_type"] + if plot_type == "plot": + kwargs = line["kwargs"] + marker = kwargs.get("marker") + mode = "lines+markers" if marker is not None else "lines" + trace = go.Scatter( + x=tx(line["x"]), + y=ty(line["y"]), + mode=mode, + name=kwargs.get("label", ""), + showlegend=bool(kwargs.get("label")) and bool(self._legend), + line=dict( + color=plotly_color(kwargs.get("color", None)), + dash=linestyle_map.get( + kwargs.get("linestyle", "solid"), + "solid", + ), + width=kwargs.get("linewidth", None), + ), + marker=( + dict( + color=plotly_color(kwargs.get("color", None)), + symbol=marker_map.get(marker, "circle"), + size=kwargs.get("markersize", None), + ) + if marker is not None + else None ), - ), + ) + traces.append(trace) + elif plot_type == "scatter": + kwargs = line["kwargs"] + marker = kwargs.get("marker", "circle") + trace = go.Scatter( + x=tx(line["x"]), + y=ty(line["y"]), + mode="markers", + name=kwargs.get("label", ""), + showlegend=bool(kwargs.get("label")) and bool(self._legend), + marker=dict( + color=plotly_color(kwargs.get("color", None)), + symbol=marker_map.get(marker, marker), + size=kwargs.get("s", None), + ), + ) + traces.append(trace) + elif plot_type == "bar": + kwargs = line["kwargs"] + trace = go.Bar( + x=tx(line["x"]), + y=np.asarray(line["height"]) * self._yscale, + name=kwargs.get("label", ""), + showlegend=bool(kwargs.get("label")) and bool(self._legend), + marker_color=plotly_color(kwargs.get("color", None)), + ) + traces.append(trace) + elif plot_type == "fill_between": + kwargs = line["kwargs"] + x = tx(line["x"]) + if np.isscalar(line["y1"]): + y1 = np.full_like( + np.asarray(x, dtype=float), float(tys(line["y1"])) + ) + else: + y1 = ty(line["y1"]) + if np.isscalar(line["y2"]): + y2 = np.full_like( + np.asarray(x, dtype=float), float(tys(line["y2"])) + ) + else: + y2 = ty(line["y2"]) + + color = plotly_color(kwargs.get("color", kwargs.get("facecolor", None))) + alpha = kwargs.get("alpha", 0.3) + fill_trace = go.Scatter( + x=np.concatenate([x, x[::-1]]), + y=np.concatenate([y1, y2[::-1]]), + fill="toself", + fillcolor=color, + opacity=alpha, + line=dict(color="rgba(0,0,0,0)"), + name=kwargs.get("label", ""), + showlegend=bool(kwargs.get("label")) and bool(self._legend), + ) + traces.append(fill_trace) + elif plot_type == "errorbar": + kwargs = line["kwargs"] + marker = kwargs.get("marker") + mode = "lines+markers" if marker is not None else "lines" + x_vals = tx(line["x"]) + y_vals = ty(line["y"]) + yerr = line.get("yerr") + xerr = line.get("xerr") + if yerr is not None and np.isscalar(yerr): + yerr = np.full(len(x_vals), float(yerr)) + if xerr is not None and np.isscalar(xerr): + xerr = np.full(len(x_vals), float(xerr)) + trace = go.Scatter( + x=x_vals, + y=y_vals, + mode=mode, + name=kwargs.get("label", ""), + showlegend=bool(kwargs.get("label")) and bool(self._legend), + line=dict( + color=plotly_color(kwargs.get("color", None)), + dash=linestyle_map.get( + kwargs.get("linestyle", "solid"), "solid" + ), + width=kwargs.get("linewidth", None), + ), + marker=( + dict( + color=plotly_color(kwargs.get("color", None)), + symbol=marker_map.get(marker, "circle"), + size=kwargs.get("markersize", None), + ) + if marker is not None + else None + ), + error_y=( + dict(type="data", array=yerr, visible=True) + if yerr is not None + else None + ), + error_x=( + dict(type="data", array=xerr, visible=True) + if xerr is not None + else None + ), + ) + traces.append(trace) + elif plot_type in ("axhline", "axvline", "hlines", "vlines"): + kwargs = line["kwargs"] + color = plotly_color(kwargs.get("color", kwargs.get("colors", "black"))) + dash = linestyle_map.get(kwargs.get("linestyle", "solid"), "solid") + width = kwargs.get("linewidth", 1) + if plot_type == "axhline": + shapes.append( + dict( + type="line", + x0=0, + x1=1, + xref="paper", + y0=tys(line["y"]), + y1=tys(line["y"]), + line=dict(color=color, dash=dash, width=width), + ) + ) + elif plot_type == "axvline": + shapes.append( + dict( + type="line", + y0=0, + y1=1, + yref="paper", + x0=txs(line["x"]), + x1=txs(line["x"]), + line=dict(color=color, dash=dash, width=width), + ) + ) + elif plot_type == "hlines": + y_vals = np.atleast_1d(line["y"]) + xmins = np.atleast_1d(line["xmin"]) + xmaxs = np.atleast_1d(line["xmax"]) + for y, xmin, xmax in zip(y_vals, xmins, xmaxs): + shapes.append( + dict( + type="line", + x0=txs(xmin), + x1=txs(xmax), + y0=tys(y), + y1=tys(y), + line=dict(color=color, dash=dash, width=width), + ) + ) + elif plot_type == "vlines": + x_vals = np.atleast_1d(line["x"]) + ymins = np.atleast_1d(line["ymin"]) + ymaxs = np.atleast_1d(line["ymax"]) + for x, ymin, ymax in zip(x_vals, ymins, ymaxs): + shapes.append( + dict( + type="line", + x0=txs(x), + x1=txs(x), + y0=tys(ymin), + y1=tys(ymax), + line=dict(color=color, dash=dash, width=width), + ) + ) + elif plot_type in ("text", "annotate"): + kwargs = line["kwargs"] + if plot_type == "text": + x = txs(float(line["x"])) + y = tys(float(line["y"])) + text = line["s"] + annotations.append( + dict( + x=x, + y=y, + text=text, + showarrow=False, + font=dict( + color=plotly_color(kwargs.get("color", None)), + size=kwargs.get("fontsize", None), + ), + ) + ) + else: + x = txs(float(line["xy"][0])) + y = tys(float(line["xy"][1])) + ann = dict( + x=x, + y=y, + text=line["text"], + showarrow=True, + arrowhead=2, + ax=0, + ay=-30, + font=dict( + color=plotly_color(kwargs.get("color", None)), + size=kwargs.get("fontsize", None), + ), + ) + if line.get("xytext") is not None: + tx_val = txs(float(line["xytext"][0])) + ty_val = tys(float(line["xytext"][1])) + ann.update(axref="x", ayref="y", ax=tx_val, ay=ty_val) + annotations.append(ann) + elif plot_type == "imshow": + kwargs = line["kwargs"] + heatmap = go.Heatmap( + z=line["data"], + colorscale=kwargs.get("cmap", "Viridis"), + showscale=True, + ) + traces.append(heatmap) + last_heatmap_idx = len(traces) - 1 + elif plot_type == "colorbar": + if last_heatmap_idx is not None: + label = line.get("label", "") or line["kwargs"].get("label", "") + if label: + traces[last_heatmap_idx].update( + colorbar=dict(title=dict(text=label)) + ) + elif plot_type == "patch": + kwargs = line["kwargs"] + patch = line["patch"] + try: + import matplotlib.patches as mpl_patches + except Exception: + mpl_patches = None + + def _patch_line_color(): + return plotly_color( + kwargs.get( + "edgecolor", + kwargs.get( + "color", + ( + patch.get_edgecolor() + if hasattr(patch, "get_edgecolor") + else "black" + ), + ), + ) + ) + + def _patch_fill_color(): + return plotly_color( + kwargs.get( + "facecolor", + ( + patch.get_facecolor() + if hasattr(patch, "get_facecolor") + else None + ), + ) + ) + + patch_label = kwargs.get("label") + if patch_label is None and hasattr(patch, "get_label"): + raw = patch.get_label() + if raw and not str(raw).startswith("_"): + patch_label = str(raw) + + hovertext = kwargs.get("hovertext") + + def _add_hover_trace(x_pts, y_pts, hovertext=hovertext): + # Plotly shapes can't show hover info themselves, so an + # invisible filled polygon trace is overlaid on top of + # the shape's outline to make the whole area hoverable. + if hovertext is None: + return + traces.append( + go.Scatter( + x=list(x_pts) + [x_pts[0]], + y=list(y_pts) + [y_pts[0]], + mode="lines", + fill="toself", + fillcolor="rgba(0,0,0,0)", + line=dict(width=0), + hoveron="fills", + hoverinfo="text", + hovertext=hovertext, + showlegend=False, + ) + ) + + if mpl_patches is not None and isinstance(patch, mpl_patches.Rectangle): + x0 = txs(patch.get_x()) + y0 = tys(patch.get_y()) + x1 = txs(patch.get_x() + patch.get_width()) + y1 = tys(patch.get_y() + patch.get_height()) + shapes.append( + dict( + type="rect", + x0=x0, + y0=y0, + x1=x1, + y1=y1, + line=dict(color=_patch_line_color()), + fillcolor=_patch_fill_color(), + opacity=kwargs.get("alpha", None), + ) + ) + patch_bounds_x.extend([x0, x1]) + patch_bounds_y.extend([y0, y1]) + _add_hover_trace([x0, x1, x1, x0], [y0, y0, y1, y1]) + elif mpl_patches is not None and isinstance(patch, mpl_patches.Circle): + cx = txs(patch.center[0]) + cy = tys(patch.center[1]) + rx = abs(txs(patch.center[0] + patch.radius) - cx) + ry = abs(tys(patch.center[1] + patch.radius) - cy) + path = ( + f"M {cx - rx},{cy} " + f"A {rx},{ry} 0 1,0 {cx + rx},{cy} " + f"A {rx},{ry} 0 1,0 {cx - rx},{cy} Z" + ) + shapes.append( + dict( + type="path", + path=path, + line=dict(color=_patch_line_color()), + fillcolor=_patch_fill_color(), + opacity=kwargs.get("alpha", None), + ) + ) + patch_bounds_x.extend([cx - rx, cx + rx]) + patch_bounds_y.extend([cy - ry, cy + ry]) + angles = np.linspace(0, 2 * np.pi, 32, endpoint=False) + _add_hover_trace(cx + rx * np.cos(angles), cy + ry * np.sin(angles)) + elif mpl_patches is not None and isinstance(patch, mpl_patches.Ellipse): + cx = txs(patch.center[0]) + cy = tys(patch.center[1]) + rx = abs(txs(patch.center[0] + patch.width / 2.0) - cx) + ry = abs(tys(patch.center[1] + patch.height / 2.0) - cy) + # Ignore rotation for now; provides useful parity for tutorials. + path = ( + f"M {cx - rx},{cy} " + f"A {rx},{ry} 0 1,0 {cx + rx},{cy} " + f"A {rx},{ry} 0 1,0 {cx - rx},{cy} Z" + ) + shapes.append( + dict( + type="path", + path=path, + line=dict(color=_patch_line_color()), + fillcolor=_patch_fill_color(), + opacity=kwargs.get("alpha", None), + ) + ) + patch_bounds_x.extend([cx - rx, cx + rx]) + patch_bounds_y.extend([cy - ry, cy + ry]) + angles = np.linspace(0, 2 * np.pi, 32, endpoint=False) + _add_hover_trace(cx + rx * np.cos(angles), cy + ry * np.sin(angles)) + elif mpl_patches is not None and isinstance(patch, mpl_patches.Polygon): + pts = patch.get_xy() + if len(pts) >= 2: + pts_t = [(txs(float(x)), tys(float(y))) for x, y in pts] + path = "M " + " L ".join(f"{x},{y}" for x, y in pts_t) + " Z" + shapes.append( + dict( + type="path", + path=path, + line=dict(color=_patch_line_color()), + fillcolor=_patch_fill_color(), + opacity=kwargs.get("alpha", None), + ) + ) + patch_bounds_x.extend(x for x, _ in pts_t) + patch_bounds_y.extend(y for _, y in pts_t) + _add_hover_trace([x for x, _ in pts_t], [y for _, y in pts_t]) + + # Plotly shapes don't participate in legends; add a dummy trace. + if patch_label and bool(self._legend): + traces.append( + go.Scatter( + x=[None], + y=[None], + mode="lines", + name=patch_label, + line=dict(color=_patch_line_color()), + showlegend=True, + ) + ) + + if patch_bounds_x: + traces.append( + go.Scatter( + x=patch_bounds_x, + y=patch_bounds_y, + mode="markers", + marker=dict(opacity=0), + showlegend=False, + hoverinfo="skip", + ) + ) + + return traces, shapes, annotations + + def _iter_layer_lines(self, layers=None): + for layer_name in sorted(self.layered_line_data): + layer_lines = self.layered_line_data[layer_name] + if layers and layer_name not in layers: + continue + for line in layer_lines: + yield line + + def _symlog_transform(self, values): + array = np.asarray(values, dtype=float) + return np.sign(array) * np.log10(1.0 + np.abs(array)) + + def _symlog_inverse(self, values): + array = np.asarray(values, dtype=float) + return np.sign(array) * (10 ** np.abs(array) - 1.0) + + def _plotext_axis_scale(self, axis: str): + return self._xaxis_scale if axis == "x" else self._yaxis_scale + + def _plotext_axis_transform(self, values, axis: str): + array = np.asarray(values) + if axis == "x": + transformed = (array + self._xshift) * self._xscale + else: + transformed = (array + self._yshift) * self._yscale + if self._plotext_axis_scale(axis) == "symlog": + return self._symlog_transform(transformed) + return transformed + + def _transform_x(self, values): + return self._plotext_axis_transform(values, "x") + + def _transform_y(self, values): + return self._plotext_axis_transform(values, "y") + + def _transform_scalar_x(self, value): + return float(np.asarray(self._transform_x([value]))[0]) + + def _transform_scalar_y(self, value): + return float(np.asarray(self._transform_y([value]))[0]) + + def _plotext_plot_kwargs(self, kwargs): + return { + key: kwargs[key] + for key in ("marker", "color", "label") + if kwargs.get(key) is not None + } + + def _plotext_scatter_kwargs(self, kwargs): + filtered = self._plotext_plot_kwargs(kwargs) + if kwargs.get("style") is not None: + filtered["style"] = kwargs["style"] + return filtered + + def _plotext_bar_kwargs(self, kwargs): + return { + key: kwargs[key] + for key in ("marker", "color", "fill", "width", "label") + if kwargs.get(key) is not None + } + + def _plotext_text_kwargs(self, kwargs): + return { + key: kwargs[key] + for key in ("color", "background", "style", "orientation", "alignment") + if kwargs.get(key) is not None + } + + def _plotext_native(self, value): + return value.item() if isinstance(value, np.generic) else value + + def _plotext_color(self, *candidates): + for color in candidates: + if color is None: + continue + if isinstance(color, tuple) and len(color) >= 4 and color[3] == 0: + continue + return color + return None + + def _plotext_patch_style(self, patch, kwargs): + edgecolor = kwargs.get( + "edgecolor", + kwargs.get( + "color", + patch.get_edgecolor() if hasattr(patch, "get_edgecolor") else None, + ), + ) + facecolor = kwargs.get( + "facecolor", + patch.get_facecolor() if hasattr(patch, "get_facecolor") else None, + ) + fill = kwargs.get("fill") + if fill is None and hasattr(patch, "get_fill"): + fill = bool(patch.get_fill()) + fill = bool(fill) + color = self._plotext_color(facecolor if fill else None, edgecolor, facecolor) + label = kwargs.get("label") + if label is None and hasattr(patch, "get_label"): + patch_label = patch.get_label() + if patch_label and not str(patch_label).startswith("_"): + label = patch_label + return color, fill, label + + def _plotext_patch_vertices(self, patch): + if hasattr(patch, "get_path") and hasattr(patch, "get_patch_transform"): + vertices = np.asarray( + patch.get_path().transformed(patch.get_patch_transform()).vertices + ) + elif hasattr(patch, "get_xy"): + vertices = np.asarray(patch.get_xy()) + else: + raise NotImplementedError( + f"plotext backend does not support patch type: {type(patch).__name__}" + ) + if vertices.size == 0: + return vertices.reshape(0, 2) + if vertices.shape[1] != 2: + raise NotImplementedError( + f"plotext backend does not support patch type: {type(patch).__name__}" + ) + return vertices + + def _plotext_patch_bounds(self, patch): + vertices = self._plotext_patch_vertices(patch) + if vertices.size == 0: + return [], [] + return vertices[:, 0].tolist(), vertices[:, 1].tolist() + + def _plotext_apply_patch_transform(self, vertices): + if vertices.size == 0: + return vertices + transformed = np.empty_like(vertices, dtype=float) + transformed[:, 0] = self._transform_x(vertices[:, 0]) + transformed[:, 1] = self._transform_y(vertices[:, 1]) + return transformed + + def _plotext_draw_patch(self, ax, patch, kwargs): + color, fill, label = self._plotext_patch_style(patch, kwargs) + vertices = self._plotext_apply_patch_transform( + self._plotext_patch_vertices(patch) + ) + if vertices.size == 0: + return color + if not np.array_equal(vertices[0], vertices[-1]): + vertices = np.vstack([vertices, vertices[0]]) + plot_kwargs = {"color": color, "label": label} + if fill: + plot_kwargs["fillx"] = "internal" + ax.plot(vertices[:, 0].tolist(), vertices[:, 1].tolist(), **plot_kwargs) + return color + + def _coerce_numeric_array(self, values): + if values is None: + return None + array = np.asarray(values) + if array.ndim == 0: + array = array.reshape(1) + try: + return array.astype(float) + except (TypeError, ValueError): + return None + + def _plotext_bounds(self, layers=None): + xs = [] + ys = [] + + def extend_x(values): + array = self._coerce_numeric_array(values) + if array is not None: + xs.extend(array.tolist()) + + def extend_y(values): + array = self._coerce_numeric_array(values) + if array is not None: + ys.extend(array.tolist()) + + for line in self._iter_layer_lines(layers=layers): + plot_type = line["plot_type"] + if plot_type in {"plot", "scatter", "errorbar"}: + x = self._transform_x(line["x"]) + y = self._transform_y(line["y"]) + extend_x(x) + extend_y(y) + xerr = self._coerce_numeric_array(line.get("xerr")) + yerr = self._coerce_numeric_array(line.get("yerr")) + if xerr is not None: + extend_x(x - xerr) + extend_x(x + xerr) + if yerr is not None: + extend_y(y - yerr) + extend_y(y + yerr) + elif plot_type == "bar": + extend_x(self._transform_x(line["x"])) + extend_y(np.asarray(line["height"]) * self._yscale) + extend_y([self._transform_scalar_y(0)]) + elif plot_type == "fill_between": + extend_x(self._transform_x(line["x"])) + y1 = ( + line["y1"] + if np.isscalar(line["y1"]) + else self._transform_y(line["y1"]) + ) + y2 = ( + self._transform_scalar_y(line["y2"]) + if np.isscalar(line["y2"]) + else self._transform_y(line["y2"]) + ) + extend_y(y1) + extend_y(y2) + elif plot_type == "hlines": + extend_y(self._transform_y(line["y"])) + extend_x(self._transform_x(line["xmin"])) + extend_x(self._transform_x(line["xmax"])) + elif plot_type == "vlines": + extend_x(self._transform_x(line["x"])) + extend_y(self._transform_y(line["ymin"])) + extend_y(self._transform_y(line["ymax"])) + elif plot_type == "axhline": + extend_y([self._transform_scalar_y(line["y"])]) + elif plot_type == "axvline": + extend_x([self._transform_scalar_x(line["x"])]) + elif plot_type == "annotate": + extend_x([self._transform_scalar_x(line["xy"][0])]) + extend_y([self._transform_scalar_y(line["xy"][1])]) + if line["xytext"] is not None: + extend_x([self._transform_scalar_x(line["xytext"][0])]) + extend_y([self._transform_scalar_y(line["xytext"][1])]) + elif plot_type == "text": + extend_x([self._transform_scalar_x(line["x"])]) + extend_y([self._transform_scalar_y(line["y"])]) + elif plot_type == "imshow": + data = np.asarray(line["data"]) + if data.ndim >= 2 and data.shape[0] and data.shape[1]: + extend_x([0, data.shape[1] - 1]) + extend_y([0, data.shape[0] - 1]) + elif plot_type == "patch": + patch_xs, patch_ys = self._plotext_patch_bounds(line["patch"]) + extend_x(self._transform_x(patch_xs)) + extend_y(self._transform_y(patch_ys)) + return xs, ys + + def _plotext_error_values(self, error, count): + if error is None: + return None + if np.isscalar(error): + return [float(error)] * count + return np.asarray(error).tolist() + + def _plotext_ranges(self, layers=None): + xs, ys = self._plotext_bounds(layers=layers) + xmin = self._xmin if self._xmin is not None else (min(xs) if xs else None) + xmax = self._xmax if self._xmax is not None else (max(xs) if xs else None) + ymin = self._ymin if self._ymin is not None else (min(ys) if ys else None) + ymax = self._ymax if self._ymax is not None else (max(ys) if ys else None) + return xmin, xmax, ymin, ymax + + def _plotext_format_tick(self, value): + value = float(value) + if abs(value) >= 1000 or (0 < abs(value) < 0.01): + return f"{value:.1e}" + if value.is_integer(): + return str(int(value)) + return f"{value:.3g}" + + def _plotext_axis_limit(self, value, axis: str): + if value is None: + return None + if axis == "x": + return self._transform_scalar_x(value) + return self._transform_scalar_y(value) + + def _plotext_symlog_ticks(self, axis: str, lower, upper, ticks=None, labels=None): + if ticks is not None: + positions = self._plotext_axis_transform(ticks, axis).tolist() + if labels is None: + labels = [self._plotext_format_tick(tick) for tick in np.asarray(ticks)] + return positions, list(labels) + + if lower is None or upper is None: + return None, None + + positions = np.linspace(lower, upper, 5) + labels = [ + self._plotext_format_tick(value) + for value in self._symlog_inverse(positions) + ] + return positions.tolist(), labels + + def _plotext_apply_aspect(self, ax, layers=None): + if self._aspect in (None, "auto"): + return + if self._aspect == "equal": + aspect = 1.0 + elif isinstance(self._aspect, (int, float)): + aspect = float(self._aspect) + else: + raise NotImplementedError( + f"plotext backend does not support aspect={self._aspect!r}" + ) + if aspect <= 0: + raise ValueError("Aspect ratio must be positive.") + + xmin, xmax, ymin, ymax = self._plotext_ranges(layers=layers) + if None in (xmin, xmax, ymin, ymax): + return + x_span = abs(xmax - xmin) or 1.0 + y_span = abs(ymax - ymin) or 1.0 + height = 16 + width = int(round(height * (x_span / (y_span * aspect)) * 2.0)) + title_hint = len( + " | ".join( + [ + part + for part in [self._title, getattr(self, "_caption", None)] + if part + ] + ) + ) + width = max(40, title_hint + 6, min(width, 80)) + ax.plotsize(width, height) + + def _plotext_colorbar_note(self, image_data, label): + data = np.asarray(image_data) + if data.size == 0: + return label or "colorbar" + finite = data[np.isfinite(data)] + if finite.size == 0: + return label or "colorbar" + vmin = float(np.min(finite)) + vmax = float(np.max(finite)) + prefix = f"{label}: " if label else "" + return f"{prefix}{self._plotext_format_tick(vmin)}..{self._plotext_format_tick(vmax)}" + + def _plotext_add_legend(self, ax, entries, layers=None): + if self._xaxis_scale in {"log", "symlog"} or self._yaxis_scale in { + "log", + "symlog", + }: + return + + unique_entries = [] + seen = set() + for label, color in entries: + if not label or label in seen: + continue + unique_entries.append((label, color)) + seen.add(label) + if not unique_entries: + return + + xmin, xmax, ymin, ymax = self._plotext_ranges(layers=layers) + if None in (xmin, xmax, ymin, ymax): + return + + x_span = xmax - xmin or 1.0 + y_span = ymax - ymin or 1.0 + x_pos = xmin + 0.7 * x_span + y_pos = ymax - 0.08 * y_span + y_step = 0.08 * y_span + + for index, (label, color) in enumerate(unique_entries): + ax.text( + label, + x_pos, + y_pos - index * y_step, + color=color, + alignment="left", ) - traces.append(trace) - return traces + def plot_plotext(self, ax, layers=None): + legend_entries = [] + colorbar_notes = [] + last_image_data = None + for line in self._iter_layer_lines(layers=layers): + plot_type = line["plot_type"] + kwargs = line["kwargs"] + if plot_type == "plot": + x = self._transform_x(line["x"]).tolist() + y = self._transform_y(line["y"]).tolist() + plot_kwargs = self._plotext_plot_kwargs(kwargs) + ax.plot(x, y, **plot_kwargs) + legend_entries.append((kwargs.get("label"), kwargs.get("color"))) + elif plot_type == "scatter": + x = self._transform_x(line["x"]).tolist() + y = self._transform_y(line["y"]).tolist() + scatter_kwargs = self._plotext_scatter_kwargs(kwargs) + ax.scatter(x, y, **scatter_kwargs) + legend_entries.append((kwargs.get("label"), kwargs.get("color"))) + elif plot_type == "bar": + transformed_heights = np.asarray(line["height"]) * self._yscale + bar_kwargs = self._plotext_bar_kwargs(kwargs) + if self._plotext_axis_scale("y") == "symlog": + transformed_heights = self._symlog_transform(transformed_heights) + bar_kwargs["minimum"] = 0.0 + ax.bar( + self._transform_x(line["x"]).tolist(), + transformed_heights.tolist(), + **bar_kwargs, + ) + legend_entries.append((kwargs.get("label"), kwargs.get("color"))) + elif plot_type == "fill_between": + x = self._transform_x(line["x"]).tolist() + y1 = self._transform_y(line["y1"]).tolist() + plot_kwargs = self._plotext_plot_kwargs(kwargs) + if np.isscalar(line["y2"]): + plot_kwargs["filly"] = line["y2"] + ax.plot(x, y1, **plot_kwargs) + else: + y2 = self._transform_y(line["y2"]).tolist() + polygon_x = x + x[::-1] + polygon_y = y1 + y2[::-1] + plot_kwargs["fillx"] = "internal" + ax.plot(polygon_x, polygon_y, **plot_kwargs) + legend_entries.append((kwargs.get("label"), kwargs.get("color"))) + elif plot_type == "errorbar": + x = self._transform_x(line["x"]).tolist() + y = self._transform_y(line["y"]).tolist() + ax.error( + x, + y, + xerr=self._plotext_error_values(line["xerr"], len(x)), + yerr=self._plotext_error_values(line["yerr"], len(y)), + color=kwargs.get("color"), + label=kwargs.get("label"), + ) + legend_entries.append((kwargs.get("label"), kwargs.get("color"))) + elif plot_type == "hlines": + ys = np.atleast_1d(line["y"]) + xmins = np.atleast_1d(line["xmin"]) + xmaxs = np.atleast_1d(line["xmax"]) + count = max(len(ys), len(xmins), len(xmaxs)) + ys = np.resize(ys, count) + xmins = np.resize(xmins, count) + xmaxs = np.resize(xmaxs, count) + for y, xmin, xmax in zip(ys, xmins, xmaxs): + ax.plot( + self._transform_x([xmin, xmax]).tolist(), + [self._transform_scalar_y(y), self._transform_scalar_y(y)], + color=kwargs.get("color"), + ) + elif plot_type == "vlines": + xs = np.atleast_1d(line["x"]) + ymins = np.atleast_1d(line["ymin"]) + ymaxs = np.atleast_1d(line["ymax"]) + count = max(len(xs), len(ymins), len(ymaxs)) + xs = np.resize(xs, count) + ymins = np.resize(ymins, count) + ymaxs = np.resize(ymaxs, count) + for x, ymin, ymax in zip(xs, ymins, ymaxs): + ax.plot( + [self._transform_scalar_x(x), self._transform_scalar_x(x)], + self._transform_y([ymin, ymax]).tolist(), + color=kwargs.get("color"), + ) + elif plot_type == "annotate": + text_x, text_y = ( + line["xytext"] if line["xytext"] is not None else line["xy"] + ) + arrowprops = kwargs.get("arrowprops") + text_x = self._plotext_native(self._transform_scalar_x(text_x)) + text_y = self._plotext_native(self._transform_scalar_y(text_y)) + xy_x = self._plotext_native(self._transform_scalar_x(line["xy"][0])) + xy_y = self._plotext_native(self._transform_scalar_y(line["xy"][1])) + if arrowprops: + arrow_color = ( + arrowprops.get("color") + if isinstance(arrowprops, dict) + else kwargs.get("color") + ) + ax.plot( + [text_x, xy_x], + [text_y, xy_y], + color=arrow_color, + ) + ax.text( + line["text"], + text_x, + text_y, + **self._plotext_text_kwargs(kwargs), + ) + elif plot_type == "text": + ax.text( + line["s"], + self._transform_scalar_x(line["x"]), + self._transform_scalar_y(line["y"]), + **self._plotext_text_kwargs(kwargs), + ) + elif plot_type == "imshow": + unsupported = set(kwargs) - {"marker", "style", "fast"} + if unsupported: + unsupported_str = ", ".join(sorted(unsupported)) + raise NotImplementedError( + f"plotext backend does not support imshow kwargs: {unsupported_str}" + ) + ax.matrix_plot( + np.asarray(line["data"]).tolist(), + marker=kwargs.get("marker"), + style=kwargs.get("style"), + fast=kwargs.get("fast", False), + ) + last_image_data = line["data"] + elif plot_type == "patch": + patch_color = self._plotext_draw_patch(ax, line["patch"], kwargs) + patch_label = kwargs.get("label") + if patch_label is None and hasattr(line["patch"], "get_label"): + candidate = line["patch"].get_label() + if candidate and not str(candidate).startswith("_"): + patch_label = candidate + legend_entries.append((patch_label, patch_color)) + elif plot_type == "colorbar": + colorbar_notes.append( + self._plotext_colorbar_note(last_image_data, line.get("label")) + ) + elif plot_type == "axhline": + ax.horizontal_line( + self._transform_scalar_y(line["y"]), + color=kwargs.get("color"), + ) + elif plot_type == "axvline": + ax.vertical_line( + self._transform_scalar_x(line["x"]), + color=kwargs.get("color"), + ) + else: + raise NotImplementedError( + f"plotext backend does not support plot type: {plot_type}" + ) + + self._plotext_apply_aspect(ax, layers=layers) + title_parts = [ + part for part in [self._title, getattr(self, "_caption", None)] if part + ] + if colorbar_notes: + title_parts.extend(colorbar_notes) + if title_parts: + ax.title(" | ".join(title_parts)) + if self._xlabel: + ax.xlabel(self._xlabel) + if self._ylabel: + ax.ylabel(self._ylabel) + if self._grid: + ax.grid(True, True) + if self.xmin is not None or self.xmax is not None: + ax.xlim( + self._plotext_axis_limit(self.xmin, "x"), + self._plotext_axis_limit(self.xmax, "x"), + ) + if self.ymin is not None or self.ymax is not None: + ax.ylim( + self._plotext_axis_limit(self.ymin, "y"), + self._plotext_axis_limit(self.ymax, "y"), + ) + if self._xaxis_scale is not None: + if self._xaxis_scale not in {"linear", "log", "symlog"}: + raise NotImplementedError( + f"plotext backend does not support xscale={self._xaxis_scale!r}" + ) + if self._xaxis_scale == "log": + ax.xscale("log") + if self._yaxis_scale is not None: + if self._yaxis_scale not in {"linear", "log", "symlog"}: + raise NotImplementedError( + f"plotext backend does not support yscale={self._yaxis_scale!r}" + ) + if self._yaxis_scale == "log": + ax.yscale("log") + if self._xticks is not None: + if self._xaxis_scale == "symlog": + positions, labels = self._plotext_symlog_ticks( + "x", + self._plotext_axis_limit(self.xmin, "x"), + self._plotext_axis_limit(self.xmax, "x"), + ticks=self._xticks, + labels=self._xticklabels, + ) + ax.xticks(positions, labels) + else: + ax.xticks(self._transform_x(self._xticks).tolist(), self._xticklabels) + elif self._xaxis_scale == "symlog": + positions, labels = self._plotext_symlog_ticks( + "x", + self._plotext_axis_limit(self.xmin, "x"), + self._plotext_axis_limit(self.xmax, "x"), + ) + if positions is not None: + ax.xticks(positions, labels) + if self._yticks is not None: + if self._yaxis_scale == "symlog": + positions, labels = self._plotext_symlog_ticks( + "y", + self._plotext_axis_limit(self.ymin, "y"), + self._plotext_axis_limit(self.ymax, "y"), + ticks=self._yticks, + labels=self._yticklabels, + ) + ax.yticks(positions, labels) + else: + ax.yticks(self._transform_y(self._yticks).tolist(), self._yticklabels) + elif self._yaxis_scale == "symlog": + positions, labels = self._plotext_symlog_ticks( + "y", + self._plotext_axis_limit(self.ymin, "y"), + self._plotext_axis_limit(self.ymax, "y"), + ) + if positions is not None: + ax.yticks(positions, labels) + if self._legend: + self._plotext_add_legend(ax, legend_entries, layers=layers) @property def xmin(self): @@ -304,10 +1704,9 @@ def legend(self, value): if __name__ == "__main__": - plotter = LinePlot() - plotter.add_line("Line 1", [0, 1, 2, 3], [0, 1, 4, 9]) - plotter.add_line("Line 2", [0, 1, 2, 3], [0, 2, 3, 6]) - latex_code = plotter.generate_latex_plot() - with open("figures/latex_code.tex", "w") as f: - f.write(latex_code) - print(latex_code) + plotter = LinePlot(xlabel="x", ylabel="y", title="Example", legend=True) + plotter.plot([0, 1, 2, 3], [0, 1, 4, 9], label="Line 1") + plotter.plot( + [0, 1, 2, 3], [0, 2, 3, 6], linestyle="dashed", color="red", label="Line 2" + ) + plotter.scatter([0, 1, 2, 3], [0, 0.5, 2, 5], label="Scatter") diff --git a/src/maxplotlib/tests/test_canvas.py b/src/maxplotlib/tests/test_canvas.py index 847a2d5..b72dbb7 100644 --- a/src/maxplotlib/tests/test_canvas.py +++ b/src/maxplotlib/tests/test_canvas.py @@ -2,5 +2,470 @@ def test(): pass +def test_canvas_plot_tikzfigure_horizontal_subplots(): + """Test that Canvas.plot_tikzfigure() works with horizontal (1×n) layouts.""" + import numpy as np + + from maxplotlib import Canvas + + # Create a 1×2 canvas + canvas, (ax1, ax2) = Canvas.subplots(ncols=2, width="10cm", ratio=0.3) + + # Add data to both subplots + x = np.linspace(0, 2 * np.pi, 50) + ax1.plot(x, np.sin(x), label="sin(x)", color="royalblue") + ax1.set_title("Sine") + ax1.set_xlabel("x") + ax1.set_ylabel("y") + + ax2.plot(x, np.cos(x), label="cos(x)", color="tomato") + ax2.set_title("Cosine") + ax2.set_xlabel("x") + + canvas.suptitle("Trig Functions") + + # This should NOT raise NotImplementedError + result = canvas.plot_tikzfigure(verbose=False) + + # Result should be a TikzFigure or string containing LaTeX + assert result is not None + + +def test_canvas_plot_tikzfigure_three_subplots(): + """Test 1×3 layout with tikzfigure backend.""" + import numpy as np + + from maxplotlib import Canvas + + x = np.linspace(0, 2 * np.pi, 50) + canvas, axes = Canvas.subplots(ncols=3, width="12cm", ratio=0.3) + + axes[0].plot(x, np.sin(x), color="blue") + axes[0].set_title("Sin") + + axes[1].plot(x, np.cos(x), color="red") + axes[1].set_title("Cos") + + axes[2].plot(x, np.tan(x), color="green") + axes[2].set_title("Tan") + + result = canvas.plot_tikzfigure() + + assert result is not None + if isinstance(result, str): + assert "\\subfigure" in result or "subfigure" in result + + +def test_canvas_plot_tikzfigure_respects_width_and_ratio(): + import numpy as np + + from maxplotlib import Canvas + + x = np.linspace(0, 1, 5) + canvas, ax = Canvas.subplots(width="10cm", ratio=2) + ax.plot(x, x**2, color="black") + ax.set_title("Parabola") + + tikz = canvas.plot_tikzfigure().generate_tikz() + + assert "width=10cm" in tikz + assert "height=20cm" in tikz + assert "title=Parabola" in tikz + + +def test_canvas_plot_tikzfigure_vertical_not_supported(): + """Test that vertical layouts raise NotImplementedError.""" + import numpy as np + import pytest + + from maxplotlib import Canvas + + x = np.linspace(0, 2 * np.pi, 50) + # Create 2×1 layout (nrows=2) + canvas, axes = Canvas.subplots(nrows=2, width="10cm") + + axes[0].plot(x, np.sin(x)) + axes[1].plot(x, np.cos(x)) + + # Should raise NotImplementedError + with pytest.raises(NotImplementedError) as exc_info: + canvas.plot_tikzfigure() + + assert "nrows > 1" in str(exc_info.value) + + +def test_canvas_matplotlib_gridspec_kw_affects_row_spacing(): + """Test that hspace changes the vertical spacing between rows.""" + import matplotlib.pyplot as plt + import numpy as np + + from maxplotlib import Canvas + + x = np.linspace(0, 1, 5) + + tight_canvas, tight_axes = Canvas.subplots( + nrows=2, + ncols=1, + width="10cm", + ratio=0.7, + gridspec_kw={"hspace": 0.02, "wspace": 0.08}, + ) + for ax in tight_axes: + ax.plot(x, x) + tight_fig, tight_matplotlib_axes = tight_canvas.plot() + tight_gap = ( + tight_matplotlib_axes[0, 0].get_position().y0 + - tight_matplotlib_axes[1, 0].get_position().y1 + ) + + loose_canvas, loose_axes = Canvas.subplots( + nrows=2, + ncols=1, + width="10cm", + ratio=0.7, + gridspec_kw={"hspace": 0.5, "wspace": 0.08}, + ) + for ax in loose_axes: + ax.plot(x, x) + loose_fig, loose_matplotlib_axes = loose_canvas.plot() + loose_gap = ( + loose_matplotlib_axes[0, 0].get_position().y0 + - loose_matplotlib_axes[1, 0].get_position().y1 + ) + + assert loose_gap > tight_gap + plt.close(tight_fig) + plt.close(loose_fig) + + +def test_canvas_matplotlib_gridspec_kw_affects_2x2_line_spacing(): + """Test that wspace/hspace change spacing for 2×2 line subplot grids.""" + import matplotlib.pyplot as plt + import numpy as np + + from maxplotlib import Canvas + + x = np.linspace(0, 1, 20) + + tight_canvas, tight_axes = Canvas.subplots( + nrows=2, + ncols=2, + width="12cm", + ratio=0.7, + hspace=0.03, + wspace=0.03, + ) + idx = 0 + for row_axes in tight_axes: + for ax in row_axes: + ax.plot(x, (idx + 1) * x) + idx += 1 + tight_fig, tight_matplotlib_axes = tight_canvas.plot(backend="matplotlib") + tight_hgap = ( + tight_matplotlib_axes[0, 1].get_position().x0 + - tight_matplotlib_axes[0, 0].get_position().x1 + ) + tight_vgap = ( + tight_matplotlib_axes[0, 0].get_position().y0 + - tight_matplotlib_axes[1, 0].get_position().y1 + ) + + loose_canvas, loose_axes = Canvas.subplots( + nrows=2, + ncols=2, + width="12cm", + ratio=0.7, + hspace=0.45, + wspace=0.45, + ) + idx = 0 + for row_axes in loose_axes: + for ax in row_axes: + ax.plot(x, (idx + 1) * x) + idx += 1 + loose_fig, loose_matplotlib_axes = loose_canvas.plot(backend="matplotlib") + loose_hgap = ( + loose_matplotlib_axes[0, 1].get_position().x0 + - loose_matplotlib_axes[0, 0].get_position().x1 + ) + loose_vgap = ( + loose_matplotlib_axes[0, 0].get_position().y0 + - loose_matplotlib_axes[1, 0].get_position().y1 + ) + + assert loose_hgap > tight_hgap + assert loose_vgap > tight_vgap + plt.close(tight_fig) + plt.close(loose_fig) + + +def test_canvas_matplotlib_gridspec_kw_affects_2x2_imshow_spacing(): + """Test spacing control also works for 2×2 color (imshow) subplot grids.""" + import matplotlib.pyplot as plt + import numpy as np + + from maxplotlib import Canvas + + data = np.arange(100).reshape(10, 10) + + tight_canvas, tight_axes = Canvas.subplots( + nrows=2, + ncols=2, + width="12cm", + ratio=0.8, + hspace=0.03, + wspace=0.03, + ) + idx = 0 + for row_axes in tight_axes: + for ax in row_axes: + ax.add_imshow(data + idx, cmap="viridis") + ax.set_title(f"Heatmap {idx + 1}") + idx += 1 + tight_fig, tight_matplotlib_axes = tight_canvas.plot(backend="matplotlib") + tight_hgap = ( + tight_matplotlib_axes[0, 1].get_position().x0 + - tight_matplotlib_axes[0, 0].get_position().x1 + ) + tight_vgap = ( + tight_matplotlib_axes[0, 0].get_position().y0 + - tight_matplotlib_axes[1, 0].get_position().y1 + ) + + loose_canvas, loose_axes = Canvas.subplots( + nrows=2, + ncols=2, + width="12cm", + ratio=0.8, + hspace=0.45, + wspace=0.45, + ) + idx = 0 + for row_axes in loose_axes: + for ax in row_axes: + ax.add_imshow(data + idx, cmap="viridis") + ax.set_title(f"Heatmap {idx + 1}") + idx += 1 + loose_fig, loose_matplotlib_axes = loose_canvas.plot(backend="matplotlib") + loose_hgap = ( + loose_matplotlib_axes[0, 1].get_position().x0 + - loose_matplotlib_axes[0, 0].get_position().x1 + ) + loose_vgap = ( + loose_matplotlib_axes[0, 0].get_position().y0 + - loose_matplotlib_axes[1, 0].get_position().y1 + ) + + assert loose_hgap > tight_hgap + assert loose_vgap > tight_vgap + plt.close(tight_fig) + plt.close(loose_fig) + + +def test_canvas_spacing_and_gridspec_kw_are_mutually_exclusive(): + import pytest + + from maxplotlib import Canvas, SubplotSpacing + + with pytest.raises(ValueError): + Canvas( + subplot_spacing=SubplotSpacing(wspace=0.2, hspace=0.2), + gridspec_kw={"wspace": 0.3, "hspace": 0.3}, + ) + + +def test_canvas_subplots_spacing_args_and_explicit_spacing_are_mutually_exclusive(): + import pytest + + from maxplotlib import Canvas, SubplotSpacing + + with pytest.raises(ValueError): + Canvas.subplots( + wspace=0.2, + hspace=0.2, + subplot_spacing=SubplotSpacing(wspace=0.3, hspace=0.3), + ) + + +def test_canvas_usetex_reads_environment_default(monkeypatch): + from maxplotlib import Canvas + + monkeypatch.setenv("MAXPLOTLIB_USETEX", "true") + canvas = Canvas() + assert canvas.usetex is True + + +def test_canvas_usetex_constructor_overrides_environment(monkeypatch): + from maxplotlib import Canvas + + monkeypatch.setenv("MAXPLOTLIB_USETEX", "true") + canvas = Canvas(usetex=False) + assert canvas.usetex is False + + +def test_canvas_plot_usetex_precedence(monkeypatch): + import matplotlib.pyplot as plt + + import maxplotlib.canvas.canvas as canvas_module + from maxplotlib import Canvas + + captured: list[bool] = [] + + def fake_setup_tex_fonts(fontsize=10, usetex=False): + captured.append(usetex) + return {} + + monkeypatch.setattr(canvas_module, "setup_tex_fonts", fake_setup_tex_fonts) + + canvas = Canvas(usetex=True) + subplot = canvas.add_subplot() + subplot.plot([0, 1], [0, 1]) + + fig, _ = canvas.plot_matplotlib() + plt.close(fig) + + fig, _ = canvas.plot_matplotlib(usetex=False) + plt.close(fig) + + assert captured == [True, False] + + +def test_canvas_show_uses_matplotlib_show(monkeypatch): + import matplotlib.pyplot as plt + + from maxplotlib import Canvas + + calls = [] + + monkeypatch.setattr( + plt, "show", lambda *args, **kwargs: calls.append((args, kwargs)) + ) + + canvas = Canvas() + subplot = canvas.add_subplot() + subplot.plot([0, 1], [0, 1]) + + fig, axes = canvas.show() + + assert calls == [((), {})] + assert fig is not None + assert axes is not None + + +def test_show_canvas_script_invokes_canvas_show(monkeypatch): + import maxplotlib + + calls = [] + + def fake_show(self, *args, **kwargs): + calls.append((args, kwargs)) + return object(), object() + + monkeypatch.setattr(maxplotlib.Canvas, "show", fake_show) + + canvas = maxplotlib.Canvas(width="10cm", ratio=0.4) + canvas.add_line(x=[1, 2, 3], y=[4, 5, 6]) + canvas.show(backend="tikzfigure", verbose=True) + + assert calls == [((), {"backend": "tikzfigure", "verbose": True})] + + +def test_canvas_plot_uses_screen_dpi_when_not_saving(): + import matplotlib.pyplot as plt + import pytest + + from maxplotlib import Canvas + + default_fig = plt.figure() + default_dpi = default_fig.dpi + plt.close(default_fig) + + canvas = Canvas(dpi=300) + subplot = canvas.add_subplot() + subplot.plot([0, 1], [0, 1]) + + fig, _ = canvas.plot() + + assert fig.dpi == pytest.approx(default_dpi) + + +def test_canvas_without_explicit_size_uses_matplotlib_defaults(): + import matplotlib.pyplot as plt + import pytest + + from maxplotlib import Canvas + + default_fig = plt.figure() + default_size = default_fig.get_size_inches().copy() + default_dpi = default_fig.dpi + plt.close(default_fig) + + canvas = Canvas() + subplot = canvas.add_subplot() + subplot.plot([0, 1], [0, 1]) + + fig, _ = canvas.plot() + + assert fig.get_size_inches()[0] == pytest.approx(default_size[0]) + assert fig.get_size_inches()[1] == pytest.approx(default_size[1]) + assert fig.dpi == pytest.approx(default_dpi) + + +def test_canvas_savefig_uses_configured_export_dpi(monkeypatch, tmp_path): + from matplotlib.figure import Figure + + from maxplotlib import Canvas + + savefig_calls = [] + original_savefig = Figure.savefig + + def wrapped_savefig(self, *args, **kwargs): + savefig_calls.append(kwargs.get("dpi")) + return original_savefig(self, *args, **kwargs) + + monkeypatch.setattr(Figure, "savefig", wrapped_savefig) + + canvas = Canvas(dpi=300) + subplot = canvas.add_subplot() + subplot.plot([0, 1], [0, 1]) + canvas.plot() + canvas.savefig(tmp_path / "figure.png") + + assert savefig_calls[-1] == 300 + + +def test_canvas_width_in_centimeters_is_preserved(): + import pytest + + from maxplotlib import Canvas + + canvas, _ = Canvas.subplots(width="7cm") + fig, _ = canvas.plot() + + assert fig.get_size_inches()[0] == pytest.approx(7 / 2.54, abs=0.01) + + +def test_canvas_fontsize_controls_axes_text(): + import pytest + + from maxplotlib import Canvas + + canvas = Canvas(fontsize=10) + canvas.add_subplot() + canvas.set_title("Title") + canvas.set_xlabel("X") + canvas.set_ylabel("Y") + canvas.suptitle("Figure title") + + fig, axes = canvas.plot() + + ax = axes[0, 0] + assert ax.title.get_fontsize() == pytest.approx(10) + assert ax.xaxis.label.get_fontsize() == pytest.approx(10) + assert ax.yaxis.label.get_fontsize() == pytest.approx(10) + assert fig._suptitle is not None + assert fig._suptitle.get_fontsize() == pytest.approx(10) + + if __name__ == "__main__": test() diff --git a/src/maxplotlib/tests/test_plot.py b/src/maxplotlib/tests/test_plot.py index e69de29..7667ed3 100644 --- a/src/maxplotlib/tests/test_plot.py +++ b/src/maxplotlib/tests/test_plot.py @@ -0,0 +1,104 @@ +import matplotlib.pyplot as plt +import numpy as np + +from maxplotlib import Canvas + + +def test_python_example_nxm_line_subplots_spacing_changes(): + """Python example: 2x2 line subplots honor wspace/hspace settings.""" + x = np.linspace(0, 2 * np.pi, 200) + + tight_canvas, tight_axes = Canvas.subplots( + nrows=2, + ncols=2, + width="12cm", + ratio=0.7, + wspace=0.05, + hspace=0.05, + ) + for i, row in enumerate(tight_axes): + for j, ax in enumerate(row): + ax.plot(x, np.sin((i + 1) * (j + 1) * x)) + tight_fig, tight_m_axes = tight_canvas.plot(backend="matplotlib") + tight_hgap = ( + tight_m_axes[0, 1].get_position().x0 - tight_m_axes[0, 0].get_position().x1 + ) + tight_vgap = ( + tight_m_axes[0, 0].get_position().y0 - tight_m_axes[1, 0].get_position().y1 + ) + + loose_canvas, loose_axes = Canvas.subplots( + nrows=2, + ncols=2, + width="12cm", + ratio=0.7, + wspace=0.45, + hspace=0.45, + ) + for i, row in enumerate(loose_axes): + for j, ax in enumerate(row): + ax.plot(x, np.sin((i + 1) * (j + 1) * x)) + loose_fig, loose_m_axes = loose_canvas.plot(backend="matplotlib") + loose_hgap = ( + loose_m_axes[0, 1].get_position().x0 - loose_m_axes[0, 0].get_position().x1 + ) + loose_vgap = ( + loose_m_axes[0, 0].get_position().y0 - loose_m_axes[1, 0].get_position().y1 + ) + + assert loose_hgap > tight_hgap + assert loose_vgap > tight_vgap + plt.close(tight_fig) + plt.close(loose_fig) + + +def test_python_example_nxm_color_subplots_spacing_changes(): + """Python example: 2x2 color subplots (imshow) honor wspace/hspace.""" + base = np.arange(100).reshape(10, 10) + + tight_canvas, tight_axes = Canvas.subplots( + nrows=2, + ncols=2, + width="12cm", + ratio=0.8, + wspace=0.05, + hspace=0.05, + ) + idx = 0 + for row in tight_axes: + for ax in row: + ax.add_imshow(base + idx, cmap="viridis") + idx += 1 + tight_fig, tight_m_axes = tight_canvas.plot(backend="matplotlib") + tight_hgap = ( + tight_m_axes[0, 1].get_position().x0 - tight_m_axes[0, 0].get_position().x1 + ) + tight_vgap = ( + tight_m_axes[0, 0].get_position().y0 - tight_m_axes[1, 0].get_position().y1 + ) + + loose_canvas, loose_axes = Canvas.subplots( + nrows=2, + ncols=2, + width="12cm", + ratio=0.8, + wspace=0.45, + hspace=0.45, + ) + idx = 0 + for row in loose_axes: + for ax in row: + ax.add_imshow(base + idx, cmap="viridis") + idx += 1 + loose_fig, loose_m_axes = loose_canvas.plot(backend="matplotlib") + loose_hgap = ( + loose_m_axes[0, 1].get_position().x0 - loose_m_axes[0, 0].get_position().x1 + ) + loose_vgap = ( + loose_m_axes[0, 0].get_position().y0 - loose_m_axes[1, 0].get_position().y1 + ) + + assert loose_hgap > tight_hgap + assert loose_vgap > tight_vgap + plt.close(tight_fig) + plt.close(loose_fig) diff --git a/src/maxplotlib/tests/test_plotext.py b/src/maxplotlib/tests/test_plotext.py new file mode 100644 index 0000000..ee34a63 --- /dev/null +++ b/src/maxplotlib/tests/test_plotext.py @@ -0,0 +1,135 @@ +import re + +import matplotlib.patches as mpatches +import numpy as np + +from maxplotlib import Canvas +from maxplotlib.backends.plotext import PlotextFigure + +ANSI_ESCAPE_RE = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]") + + +def strip_ansi(text: str) -> str: + return ANSI_ESCAPE_RE.sub("", text) + + +def test_canvas_plot_plotext_builds_terminal_output(): + x = np.linspace(0, 2 * np.pi, 40) + canvas, ax = Canvas.subplots(width="10cm", ratio=0.5) + + ax.plot(x, np.sin(x), color="blue", label="sin(x)") + ax.scatter(x[::8], np.cos(x[::8]), color="red", label="samples") + ax.axhline(0, color="white") + ax.set_title("Terminal sine") + ax.set_xlabel("x") + ax.set_ylabel("y") + ax.set_grid(True) + ax.set_legend(True) + canvas.suptitle("Plotext demo") + + figure = canvas.plot(backend="plotext") + output = strip_ansi(figure.build()) + + assert isinstance(figure, PlotextFigure) + assert "Plotext demo" in output + assert "Terminal sine" in output + assert "sin(x)" in output + assert "samples" in output + + +def test_canvas_show_plotext_prints_output(capsys): + x = np.linspace(0, 1, 12) + canvas, ax = Canvas.subplots() + ax.plot(x, x**2, color="green") + ax.set_title("Quadratic") + + figure = canvas.show(backend="plotext") + captured = strip_ansi(capsys.readouterr().out) + + assert isinstance(figure, PlotextFigure) + assert "Quadratic" in captured + + +def test_canvas_plot_plotext_supports_scalar_errorbars(): + x = np.linspace(1, 10, 10) + canvas, ax = Canvas.subplots() + ax.errorbar(x, np.sqrt(x), yerr=0.2, color="yellow", label="samples") + ax.set_xscale("log") + ax.set_title("Log errors") + + output = strip_ansi(canvas.plot(backend="plotext").build()) + + assert "Log errors" in output + + +def test_canvas_plot_plotext_supports_fill_between_curves_and_annotations(): + x = np.linspace(0, 4, 25) + canvas, ax = Canvas.subplots() + ax.fill_between(x, np.sin(x) + 1.5, np.cos(x) + 0.5, color="cyan", label="band") + ax.annotate( + "crossing", xy=(1.5, 1.0), xytext=(2.5, 2.1), arrowprops={"color": "yellow"} + ) + ax.set_title("Filled band") + ax.set_legend(True) + + output = strip_ansi(canvas.plot(backend="plotext").build()) + + assert "Filled band" in output + assert "band" in output + assert "crossing" in output + + +def test_canvas_plot_plotext_supports_matrix_plots_and_patches(): + canvas, ax = Canvas.subplots() + ax.add_imshow(np.arange(9).reshape(3, 3)) + ax.add_patch( + mpatches.Rectangle((0.2, 0.2), 1.2, 0.8, fill=False, edgecolor="yellow") + ) + ax.add_patch(mpatches.Circle((1.8, 1.8), 0.4, fill=False, edgecolor="cyan")) + ax.set_title("Matrix plot") + + output = strip_ansi(canvas.plot(backend="plotext").build()) + + assert "Matrix plot" in output + + +def test_canvas_plot_plotext_supports_colorbar_notes_symlog_aspect_and_generic_patches(): + canvas, ax = Canvas.subplots() + ax.add_imshow(np.eye(3)) + ax.add_colorbar(label="scale") + ax.set_title("Heatmap") + output = strip_ansi(canvas.plot(backend="plotext").build()) + + assert "Heatmap" in output + assert "scale:" in output + + x = np.linspace(-20, 20, 81) + canvas, ax = Canvas.subplots() + ax.plot(x, x**3, color="cyan") + ax.set_xscale("symlog") + ax.set_yscale("symlog") + ax.set_aspect("equal") + ax.add_caption("caption text") + ax.set_title("Symlog view") + output = strip_ansi(canvas.plot(backend="plotext").build()) + + assert "Symlog view" in output + assert "caption text" in output + + canvas, ax = Canvas.subplots() + ax.add_patch( + mpatches.Ellipse( + (1.5, 1.0), + 2.0, + 1.0, + fill=False, + edgecolor="yellow", + label="ellipse", + ) + ) + ax.set_title("Generic patch") + ax.set_legend(True) + output = strip_ansi(canvas.plot(backend="plotext").build()) + + assert "Generic patch" in output + assert "ellipse" in output diff --git a/src/maxplotlib/tests/test_plotly_backend.py b/src/maxplotlib/tests/test_plotly_backend.py new file mode 100644 index 0000000..2184972 --- /dev/null +++ b/src/maxplotlib/tests/test_plotly_backend.py @@ -0,0 +1,86 @@ +import numpy as np + + +def test_plotly_backend_supports_common_primitives(): + from maxplotlib import Canvas + + x = np.linspace(0, 1, 10) + + canvas, ax = Canvas.subplots() + ax.plot(x, x, color="royalblue", label="line") + ax.scatter(x, x**2, color="tomato", label="points") + ax.errorbar(x[::2], (x**2)[::2], yerr=0.1, color="black", label="err") + ax.fill_between(x, x - 0.1, x + 0.1, color="gray", alpha=0.2, label="band") + ax.axhline(0.5, color="black", linestyle="dotted") + ax.axvline(0.25, color="black", linestyle="dashed") + ax.text(0.8, 0.8, "hi", color="purple") + ax.annotate("there", xy=(0.3, 0.3), xytext=(0.6, 0.5), color="purple") + ax.set_grid(True) + ax.set_legend(True) + + fig = canvas.plot(backend="plotly") + + assert fig is not None + assert len(fig.data) >= 4 # line, scatter, errorbar, fill_between + assert len(getattr(fig.layout, "shapes", []) or []) >= 2 # axhline + axvline + assert ( + len(getattr(fig.layout, "annotations", []) or []) >= 2 + ) # subplot title + text/annotate + + +def test_plotly_backend_respects_layers(): + from maxplotlib import Canvas + + x = np.linspace(0, 1, 10) + canvas, ax = Canvas.subplots() + ax.plot(x, x, color="black", label="L0", layer=0) + ax.plot(x, x**2, color="red", label="L1", layer=1) + + fig0 = canvas.plot(backend="plotly", layers=[0]) + fig1 = canvas.plot(backend="plotly", layers=[1]) + + assert len(fig0.data) == 1 + assert len(fig1.data) == 1 + + +def test_plotly_backend_supports_common_patches_and_symlog(): + import matplotlib.patches as mpatches + + from maxplotlib import Canvas + + canvas, ax = Canvas.subplots() + ax.add_patch( + mpatches.Rectangle( + (0.2, 0.2), 1.3, 0.7, fill=False, edgecolor="yellow", label="r" + ) + ) + ax.add_patch( + mpatches.Circle((2.2, 1.6), 0.45, fill=False, edgecolor="cyan", label="c") + ) + ax.add_patch( + mpatches.Polygon( + [[3.0, 0.5], [3.8, 1.2], [3.4, 2.0]], + fill=True, + facecolor="green", + label="p", + ) + ) + ax.add_patch( + mpatches.Ellipse((2.8, 1.0), 0.8, 0.5, fill=False, edgecolor="white", label="e") + ) + ax.set_title("patches") + ax.set_legend(True) + + fig = canvas.plot(backend="plotly") + assert fig is not None + assert len(getattr(fig.layout, "shapes", []) or []) >= 4 + # patch labels become dummy legend traces + assert any(getattr(t, "name", "") == "p" for t in fig.data) + + canvas2, ax2 = Canvas.subplots() + x = np.linspace(-20, 20, 41) + ax2.plot(x, x**3, color="cyan", label="x^3") + ax2.set_xscale("symlog") + ax2.set_yscale("symlog") + fig2 = canvas2.plot(backend="plotly") + assert fig2 is not None diff --git a/src/maxplotlib/utils/options.py b/src/maxplotlib/utils/options.py index 6666e4d..0282f92 100644 --- a/src/maxplotlib/utils/options.py +++ b/src/maxplotlib/utils/options.py @@ -1,3 +1,3 @@ from typing import Literal -Backends = Literal["matplotlib", "plotly", "tikzpics"] +Backends = Literal["matplotlib", "plotly", "plotext", "tikzfigure"] diff --git a/tutorials/tutorial_01.ipynb b/tutorials/tutorial_01.ipynb index 2b110e4..964b647 100644 --- a/tutorials/tutorial_01.ipynb +++ b/tutorials/tutorial_01.ipynb @@ -5,8 +5,17 @@ "id": "0", "metadata": {}, "source": [ + "# Tutorial 01 — Quick Start\n", "\n", - "# Tutorial 1\n" + "**maxplotlib** is a thin, expressive wrapper around Matplotlib that simplifies\n", + "creating publication-quality figures. Its central class, `Canvas`, replaces the\n", + "usual `fig, ax = plt.subplots()` boilerplate and exposes a clean, chainable API.\n", + "\n", + "This notebook walks you through the very basics:\n", + "- creating a canvas and a subplot\n", + "- adding lines\n", + "- using the canvas-level shortcut API\n", + "- saving figures" ] }, { @@ -17,98 +26,221 @@ "outputs": [], "source": [ "from maxplotlib import Canvas\n", + "import numpy as np\n", + "\n", + "%matplotlib inline" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Backend selection\n", + "\n", + "All examples in this tutorial use the same `Canvas` API; you can switch rendering backends at any time:\n", + "\n", + "- `BACKEND = \"matplotlib\"` for static Matplotlib output\n", + "- `BACKEND = \"plotly\"` for interactive Plotly output (Jupyter-friendly)\n", "\n", - "%load_ext autoreload\n", - "%autoreload 2" + "Most cells end with `canvas.show(backend=BACKEND)` so you can re-run the whole notebook with a different backend.\n", + "\n" ] }, { "cell_type": "code", "execution_count": null, - "id": "2", + "id": "3", "metadata": {}, "outputs": [], "source": [ - "c = Canvas(width=\"17mm\", ratio=0.5, fontsize=12)\n", - "c.add_line([0, 1, 2, 3], [0, 1, 4, 9], label=\"Line 1\")\n", - "c.add_line([0, 1, 2, 3], [0, 2, 3, 4], linestyle=\"dashed\", color=\"red\", label=\"Line 2\")\n", - "c.show()" + "# Change to \"plotly\" for interactive output\n", + "BACKEND = \"matplotlib\"" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "## 1 Minimal example\n", + "\n", + "`Canvas.subplots()` mirrors `plt.subplots()`. It returns the canvas and one (or\n", + "more) subplot axes objects." ] }, { "cell_type": "code", "execution_count": null, - "id": "3", + "id": "5", "metadata": {}, "outputs": [], "source": [ - "# You can also explicitly create a subplot and add lines to it\n", + "x = np.linspace(0, 2 * np.pi, 200)\n", + "y = np.sin(x)\n", "\n", - "c = Canvas(width=\"17cm\", ratio=0.5, fontsize=12)\n", - "sp = c.add_subplot(\n", - " grid=True, xlabel=\"(x - 10) * 0.1\", ylabel=\"10y\", yscale=10, xshift=-10, xscale=0.1\n", + "canvas, ax = Canvas.subplots()\n", + "ax.plot(x, y)\n", + "canvas.show(backend=BACKEND)" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "## 2 Multiple lines with labels, colors, and linestyles" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "canvas, ax = Canvas.subplots()\n", + "\n", + "ax.plot(x, np.sin(x), label=\"sin(x)\", color=\"royalblue\", linestyle=\"solid\", linewidth=2)\n", + "ax.plot(x, np.cos(x), label=\"cos(x)\", color=\"tomato\", linestyle=\"dashed\", linewidth=2)\n", + "ax.plot(\n", + " x,\n", + " np.sin(2 * x),\n", + " label=\"sin(2x)\",\n", + " color=\"forestgreen\",\n", + " linestyle=\"dotted\",\n", + " linewidth=1.5,\n", ")\n", "\n", - "sp.add_line([0, 1, 2, 3], [0, 1, 4, 9], label=\"Line 1\")\n", - "sp.add_line([0, 1, 2, 3], [0, 2, 3, 4], linestyle=\"dashed\", color=\"red\", label=\"Line 2\")\n", - "c.show()" + "ax.set_xlabel(\"x\")\n", + "ax.set_ylabel(\"y\")\n", + "ax.set_title(\"Sine and Cosine\")\n", + "ax.set_legend(True)\n", + "\n", + "canvas.show(backend=BACKEND)" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "## 3 Canvas-level shortcut API\n", + "\n", + "For single-subplot figures you can call plot methods directly on the `Canvas`\n", + "object — they are forwarded to the subplot at `row=0, col=0`." ] }, { "cell_type": "code", "execution_count": null, - "id": "4", + "id": "9", "metadata": {}, "outputs": [], "source": [ - "# Example with multiple subplots\n", - "\n", - "c = Canvas(width=\"17cm\", ncols=2, nrows=2, ratio=0.5)\n", - "sp = c.add_subplot(grid=True)\n", - "c.add_subplot(row=1)\n", - "sp2 = c.add_subplot(row=1, legend=False)\n", - "sp.add_line([0, 1, 2, 3], [0, 1, 4, 9], label=\"Line 1\")\n", - "sp2.add_line(\n", - " [0, 1, 2, 3], [0, 2, 3, 4], linestyle=\"dashed\", color=\"red\", label=\"Line 2\"\n", - ")\n", - "c.show()" + "canvas = Canvas(ratio=0.5, fontsize=12)\n", + "\n", + "canvas.add_line(x, np.sin(x), label=\"sin(x)\", color=\"steelblue\")\n", + "canvas.add_line(x, np.cos(x), label=\"cos(x)\", color=\"darkorange\", linestyle=\"dashed\")\n", + "\n", + "canvas.set_xlabel(\"angle (rad)\")\n", + "canvas.set_ylabel(\"amplitude\")\n", + "canvas.set_title(\"Using canvas-level methods\")\n", + "canvas.set_legend(True)\n", + "canvas.set_grid(True)\n", + "\n", + "canvas.show(backend=BACKEND)" + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, + "source": [ + "## 4 Configuring the subplot at creation time\n", + "\n", + "`add_subplot()` accepts convenience kwargs so you can set labels, grid, and\n", + "legend in one call." ] }, { "cell_type": "code", "execution_count": null, - "id": "5", + "id": "11", "metadata": {}, "outputs": [], "source": [ - "# Test with plotly backend\n", - "c = Canvas(width=\"17cm\", ratio=0.5)\n", - "sp = c.add_subplot(\n", - " grid=True, xlabel=\"x (mm)\", ylabel=\"10y\", yscale=10, xshift=-10, xscale=0.1\n", + "canvas = Canvas(ratio=0.5)\n", + "ax = canvas.add_subplot(\n", + " title=\"Configured at creation\",\n", + " xlabel=\"x\",\n", + " ylabel=\"f(x)\",\n", + " grid=True,\n", + " legend=True,\n", ")\n", - "sp.add_line([0, 1, 2, 3], [0, 1, 4, 9], label=\"Line 1\", linestyle=\"-.\")\n", - "sp.add_line([0, 1, 2, 3], [0, 2, 3, 4], linestyle=\"dashed\", color=\"red\", label=\"Line 2\")\n", - "c.show()" + "\n", + "ax.plot(x, np.sin(x), label=\"sin\", color=\"royalblue\")\n", + "ax.plot(x, x / (2 * np.pi), label=\"x/2π\", color=\"coral\", linestyle=\"dashed\")\n", + "\n", + "canvas.show(backend=BACKEND)" + ] + }, + { + "cell_type": "markdown", + "id": "12", + "metadata": {}, + "source": [ + "## 5 Saving a figure\n", + "\n", + "Use `canvas.savefig()` to write the figure to disk. The file extension\n", + "determines the format; pass `backend='matplotlib'` for PDF output." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13", + "metadata": {}, + "outputs": [], + "source": [ + "canvas = Canvas(ratio=0.5)\n", + "ax = canvas.add_subplot(xlabel=\"x\", ylabel=\"sin(x)\", grid=True)\n", + "ax.plot(x, np.sin(x), color=\"steelblue\")\n", + "\n", + "canvas.savefig(\"tutorial_01_output.png\")\n", + "print(\"Figure saved to tutorial_01_output.png\")" + ] + }, + { + "cell_type": "markdown", + "id": "14", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "| Task | Code |\n", + "|---|---|\n", + "| Create canvas + subplot | `canvas, ax = Canvas.subplots()` |\n", + "| Add a line | `ax.plot(x, y, label=..., color=..., linestyle=...)` |\n", + "| Canvas shortcut | `canvas.add_line(x, y, ...)` |\n", + "| Labels / title | `ax.set_xlabel()`, `ax.set_ylabel()`, `ax.set_title()` |\n", + "| Legend / grid | `ax.set_legend(True)`, `ax.set_grid(True)` |\n", + "| Display | `canvas.show()` |\n", + "| Save | `canvas.savefig('out.png')` |\n", + "\n", + "Continue to **Tutorial 02** to learn about multi-subplot layouts." ] } ], "metadata": { "kernelspec": { - "display_name": "env_maxpic", + "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.13.3" + "version": "3.10.0" } }, "nbformat": 4, diff --git a/tutorials/tutorial_02.ipynb b/tutorials/tutorial_02.ipynb index f2e3528..06f79ae 100644 --- a/tutorials/tutorial_02.ipynb +++ b/tutorials/tutorial_02.ipynb @@ -5,7 +5,16 @@ "id": "0", "metadata": {}, "source": [ - "# Tutorial 2" + "# Tutorial 02 — Multiple Subplots\n", + "\n", + "This notebook shows all the ways to build multi-panel figures with maxplotlib:\n", + "\n", + "- `Canvas.subplots(ncols=...)` / `Canvas.subplots(nrows=..., ncols=...)`\n", + "- `squeeze=False` for a consistent 2-D axes list\n", + "- Manual layout with `canvas.add_subplot(row=..., col=...)`\n", + "- Accessing subplots: `canvas.subplot()`, `canvas[row, col]`, `canvas.iter_subplots()`\n", + "- Figure-level title with `canvas.suptitle()`\n", + "- Canvas-level plot routing to a specific subplot" ] }, { @@ -16,123 +25,330 @@ "outputs": [], "source": [ "from maxplotlib import Canvas\n", + "from tikzfigure import TikzFigure\n", + "import numpy as np\n", "\n", + "%matplotlib inline\n", "%load_ext autoreload\n", - "%autoreload 2" + "%autoreload 2\n", + "\n", + "x = np.linspace(0, 2 * np.pi, 200)" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Backend selection\n", + "\n", + "All examples in this tutorial use the same `Canvas` API; you can switch rendering backends at any time:\n", + "\n", + "- `BACKEND = \"matplotlib\"` for static Matplotlib output\n", + "- `BACKEND = \"plotly\"` for interactive Plotly output (Jupyter-friendly)\n", + "\n", + "Most cells end with `canvas.show(backend=BACKEND)` so you can re-run the whole notebook with a different backend.\n", + "\n" ] }, { "cell_type": "code", "execution_count": null, - "id": "2", + "id": "3", "metadata": {}, "outputs": [], "source": [ - "c = Canvas(width=800, ratio=0.5)\n", - "tikz = c.add_tikzfigure(grid=False)\n", + "# Change to \"plotly\" for interactive output\n", + "BACKEND = \"matplotlib\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "fig = TikzFigure()\n", "\n", - "# Add nodes\n", - "tikz.add_node(0, 0, label=\"A\", shape=\"circle\", draw=\"black\", fill=\"blue\", layer=0)\n", - "tikz.add_node(1, 0, label=\"B\", shape=\"circle\", draw=\"black\", fill=\"blue\", layer=0)\n", - "tikz.add_node(1, 1, label=\"C\", shape=\"circle\", draw=\"black\", fill=\"blue\", layer=0)\n", - "tikz.add_node(0, 1, label=\"D\", shape=\"circle\", draw=\"black\", fill=\"blue\", layer=2)\n", + "x = np.linspace(0, 360, 200)\n", + "y1 = np.sin(np.radians(x))\n", + "y2 = np.cos(np.radians(x))\n", "\n", + "# First subfigure: sine wave\n", + "ax1 = fig.subfigure_axis(\n", + " xlabel=\"x\",\n", + " ylabel=\"y\",\n", + " xlim=(0, 360),\n", + " ylim=(-1.5, 1.5),\n", + " grid=True,\n", + " caption=\"Sine Function\",\n", + " width=0.45,\n", + ")\n", + "ax1.add_plot(x=x, y=y1, label=\"sin(x)\", color=\"red\", line_width=\"1.5pt\")\n", + "ax1.set_legend(position=\"north east\")\n", "\n", - "# Add a line between nodes\n", - "tikz.draw(\n", - " [\"A\", \"B\", \"C\", \"D\"],\n", - " path_actions=[\"draw\", \"rounded corners\"],\n", - " fill=\"red\",\n", - " opacity=1.0,\n", - " cycle=True,\n", - " layer=1,\n", + "# Second subfigure: cosine wave\n", + "ax2 = fig.subfigure_axis(\n", + " xlabel=\"x\",\n", + " ylabel=\"y\",\n", + " xlim=(0, 360),\n", + " ylim=(-1.5, 1.5),\n", + " grid=True,\n", + " caption=\"Cosine Function\",\n", + " width=0.45,\n", ")\n", + "ax2.add_plot(x=x, y=y2, label=\"cos(x)\", color=\"blue\", line_width=\"1.5pt\")\n", + "ax2.set_legend(position=\"north east\")\n", "\n", - "tikz.add_node(0.5, 0.5, content=\"Cube\", layer=10)\n", + "fig.show()" + ] + }, + { + "cell_type": "markdown", + "id": "5", + "metadata": {}, + "source": [ + "## 1 1×2 layout — side-by-side subplots" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], + "source": [ + "canvas, (ax1, ax2) = Canvas.subplots(ncols=2, width=1000, ratio=0.3)\n", + "\n", + "x = np.linspace(0, 2 * np.pi, 200)\n", "\n", - "# tikz.compile_pdf(\"tutorial_02_01.pdf\")\n", - "c.plot(backend=\"matplotlib\")" + "ax1.plot(x, np.sin(x), color=\"royalblue\", linewidth=1.5)\n", + "ax1.set_title(\"sin(x)\")\n", + "ax1.set_xlabel(\"x\")\n", + "ax1.set_ylabel(\"amplitude\")\n", + "\n", + "ax2.plot(x, np.cos(x), color=\"tomato\", linewidth=1.5)\n", + "ax2.set_title(\"cos(x)\")\n", + "ax2.set_xlabel(\"x\")\n", + "\n", + "canvas.suptitle(\"1 × 2 Layout\")\n", + "canvas.show(backend=\"tikzfigure\")" + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": {}, + "source": [ + "## 2 2×2 layout — grid of subplots\n", + "\n", + "`Canvas.subplots(nrows=2, ncols=2)` returns a 2-D list of subplot axes\n", + "indexed as `axes[row][col]`." ] }, { "cell_type": "code", "execution_count": null, - "id": "3", + "id": "8", "metadata": {}, "outputs": [], "source": [ - "c = Canvas(ncols=2, width=\"20cm\", ratio=0.5)\n", - "tikz = c.add_tikzfigure(grid=False)\n", - "\n", - "# Add nodes\n", - "node_a = tikz.add_node(\n", - " -5,\n", - " 0,\n", - " label=\"A\",\n", - " content=\"Origin node\",\n", - " shape=\"circle\",\n", - " draw=\"black\",\n", - " fill=\"blue!20\",\n", - ")\n", - "tikz.add_node(\n", - " 2,\n", - " 2,\n", - " label=\"B\",\n", - " content=\"$a^2 + b^2 = c^2$\",\n", - " shape=\"rectangle\",\n", - " draw=\"red\",\n", - " fill=\"white\",\n", - " layer=1,\n", - ")\n", - "tikz.add_node(2, 5, label=\"C\", shape=\"rectangle\", draw=\"red\", fill=\"red\")\n", - "last_node = tikz.add_node(-1, 5, shape=\"rectangle\", draw=\"red\", fill=\"red\", layer=-10)\n", - "\n", - "# # Add a line between nodes\n", - "tikz.draw(\n", - " [node_a.label, \"B\", \"C\", \"A\", last_node],\n", - " color=\"green\",\n", - " style=\"solid\",\n", - " line_width=\"2\",\n", - " layer=-5,\n", - ")\n", + "canvas, axes = Canvas.subplots(nrows=2, ncols=2)\n", + "\n", + "axes[0][0].plot(x, np.sin(x), color=\"royalblue\")\n", + "axes[0][0].set_title(\"sin(x)\")\n", + "axes[0][1].plot(x, np.cos(x), color=\"tomato\")\n", + "axes[0][1].set_title(\"cos(x)\")\n", + "axes[1][0].plot(x, np.sin(2 * x), color=\"seagreen\")\n", + "axes[1][0].set_title(\"sin(2x)\")\n", + "axes[1][1].plot(x, np.cos(2 * x), color=\"darkorange\")\n", + "axes[1][1].set_title(\"cos(2x)\")\n", + "\n", + "canvas.suptitle(\"2 × 2 Layout\")\n", + "canvas.show(backend=BACKEND)" + ] + }, + { + "cell_type": "markdown", + "id": "9", + "metadata": {}, + "source": [ + "## 3 `squeeze=False` — always get a 2-D list\n", "\n", - "sp = c.add_subplot(\n", - " grid=True, xlabel=\"(x - 10) * 0.1\", ylabel=\"10y\", yscale=10, xshift=-10, xscale=0.1\n", + "By default a 1×N or N×1 grid returns a flat list. Pass `squeeze=False` to\n", + "always get a 2-D nested list — useful when your layout code must be generic." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "10", + "metadata": {}, + "outputs": [], + "source": [ + "canvas, axes = Canvas.subplots(nrows=1, ncols=3, squeeze=False)\n", + "\n", + "# axes is always [[ax0, ax1, ax2]] — index as axes[row][col]\n", + "data = [np.sin(x), np.cos(x), np.tan(np.clip(x, 0, np.pi - 0.1))]\n", + "titles = [\"sin\", \"cos\", \"tan (clipped)\"]\n", + "\n", + "for col, (d, t) in enumerate(zip(data, titles)):\n", + " axes[0][col].plot(x, d, color=\"steelblue\")\n", + " axes[0][col].set_title(t)\n", + "\n", + "canvas.show(backend=BACKEND)" + ] + }, + { + "cell_type": "markdown", + "id": "11", + "metadata": {}, + "source": [ + "## 4 Manual layout — `canvas.add_subplot(row, col)`\n", + "\n", + "You can build the layout yourself by calling `add_subplot` explicitly. This\n", + "lets you configure each panel's title, labels, grid, and legend in one shot." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12", + "metadata": {}, + "outputs": [], + "source": [ + "canvas = Canvas(nrows=2, ncols=2)\n", + "\n", + "ax00 = canvas.add_subplot(\n", + " row=0, col=0, title=\"Top-left\", xlabel=\"x\", ylabel=\"y\", grid=True\n", ")\n", - "sp.add_line([0, 1, 2, 3], [0, 1, 4, 9], label=\"Line 1\")\n", - "sp.add_line([0, 1, 2, 3], [0, 2, 3, 4], linestyle=\"dashed\", color=\"red\", label=\"Line 2\")\n", + "ax01 = canvas.add_subplot(row=0, col=1, title=\"Top-right\", xlabel=\"x\", grid=True)\n", + "ax10 = canvas.add_subplot(row=1, col=0, title=\"Bottom-left\", xlabel=\"x\", ylabel=\"y\")\n", + "ax11 = canvas.add_subplot(row=1, col=1, title=\"Bottom-right\", xlabel=\"x\", legend=True)\n", "\n", - "# Generate the TikZ script\n", - "# print(tikz.generate_standalone())\n", + "ax00.plot(x, np.sin(x), color=\"royalblue\")\n", + "ax01.plot(x, np.cos(x), color=\"tomato\")\n", + "ax10.plot(x, np.sin(2 * x), color=\"seagreen\")\n", + "ax11.plot(x, np.sin(x), label=\"sin\", color=\"royalblue\")\n", + "ax11.plot(x, np.cos(x), label=\"cos\", color=\"tomato\")\n", "\n", - "# tikz.compile_pdf(\"tutorial_02_02.pdf\")\n", + "canvas.suptitle(\"Manual Layout\")\n", + "canvas.show(backend=BACKEND)" + ] + }, + { + "cell_type": "markdown", + "id": "13", + "metadata": {}, + "source": [ + "## 5 Accessing subplots after creation\n", "\n", - "c.plot(backend=\"matplotlib\")" + "Three equivalent ways to retrieve a subplot object:" ] }, { "cell_type": "code", "execution_count": null, - "id": "4", + "id": "14", + "metadata": {}, + "outputs": [], + "source": [ + "canvas, axes = Canvas.subplots(nrows=2, ncols=2)\n", + "\n", + "# Method A: use the object returned by subplots()\n", + "axes[0][0].set_title(\"Method A\")\n", + "\n", + "# Method B: canvas.subplot(row, col)\n", + "sp_b = canvas.subplot(row=0, col=1)\n", + "sp_b.set_title(\"Method B\")\n", + "\n", + "# Method C: canvas[row, col] indexing\n", + "canvas[1, 0].set_title(\"Method C\")\n", + "canvas[1, 1].set_title(\"Method D (index)\")\n", + "\n", + "canvas.show(backend=BACKEND)" + ] + }, + { + "cell_type": "markdown", + "id": "15", + "metadata": {}, + "source": [ + "## 6 `canvas.iter_subplots()` — loop over all panels" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "16", + "metadata": {}, + "outputs": [], + "source": [ + "canvas, axes = Canvas.subplots(nrows=2, ncols=2)\n", + "\n", + "# Plot something in every panel first\n", + "for row in range(2):\n", + " for col in range(2):\n", + " axes[row][col].plot(x, np.sin((row + 1) * (col + 1) * x))\n", + "\n", + "# Then enable grid on every panel uniformly\n", + "for row, col, sp in canvas.iter_subplots():\n", + " sp.set_grid(True)\n", + " sp.set_xlabel(\"x\")\n", + "\n", + "canvas.show(backend=BACKEND)" + ] + }, + { + "cell_type": "markdown", + "id": "17", + "metadata": {}, + "source": [ + "## 7 Canvas-level plot routing\n", + "\n", + "Pass `row=` and `col=` to canvas-level methods to target a specific subplot." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "18", "metadata": {}, "outputs": [], "source": [ - "c = Canvas(width=800, ratio=0.5)\n", - "tikz = c.add_tikzfigure(grid=False)\n", + "canvas = Canvas(nrows=1, ncols=2)\n", + "canvas.add_subplot(row=0, col=0, title=\"Left\", xlabel=\"x\", ylabel=\"sin\")\n", + "canvas.add_subplot(row=0, col=1, title=\"Right\", xlabel=\"x\", ylabel=\"cos\")\n", "\n", - "# Add nodes\n", - "tikz.add_node(0, 0, label=\"A\")\n", - "tikz.add_node(10, 0, label=\"B\")\n", + "canvas.add_line(x, np.sin(x), row=0, col=0, color=\"royalblue\", label=\"sin\")\n", + "canvas.add_line(x, np.cos(x), row=0, col=1, color=\"tomato\", label=\"cos\")\n", "\n", + "canvas.set_legend(True, row=0, col=0)\n", + "canvas.set_legend(True, row=0, col=1)\n", + "canvas.suptitle(\"Canvas-level routing\")\n", + "canvas.show(backend=BACKEND)" + ] + }, + { + "cell_type": "markdown", + "id": "19", + "metadata": {}, + "source": [ + "## Summary\n", "\n", - "# Add a line between nodes\n", - "tikz.draw([\"A\", \"B\"], path_actions=[\"->\"], out=30)\n", + "| Task | Code |\n", + "|---|---|\n", + "| 1×2 grid | `canvas, (ax1, ax2) = Canvas.subplots(ncols=2)` |\n", + "| 2×2 grid | `canvas, axes = Canvas.subplots(nrows=2, ncols=2)` — index `axes[r][c]` |\n", + "| Always 2-D | `Canvas.subplots(..., squeeze=False)` |\n", + "| Manual panel | `canvas.add_subplot(row=r, col=c, ...)` |\n", + "| Get subplot | `canvas.subplot(r, c)` or `canvas[r, c]` |\n", + "| Loop panels | `for row, col, sp in canvas.iter_subplots()` |\n", + "| Figure title | `canvas.suptitle('...')` |\n", + "| Route plot | `canvas.add_line(x, y, row=r, col=c)` |\n", "\n", - "# Generate the TikZ script\n", - "# script = tikz.generate_tikz()\n", - "# print(script)\n", - "print(tikz.generate_standalone())\n", - "# tikz.compile_pdf(\"tutorial_02_03.pdf\")" + "Next: **Tutorial 03** covers all the available plot types." ] } ], diff --git a/tutorials/tutorial_03.ipynb b/tutorials/tutorial_03.ipynb index a323007..c3b8e12 100644 --- a/tutorials/tutorial_03.ipynb +++ b/tutorials/tutorial_03.ipynb @@ -5,7 +5,15 @@ "id": "0", "metadata": {}, "source": [ - "# Tutorial 3" + "# Tutorial 03 — Plot Types\n", + "\n", + "maxplotlib exposes all common Matplotlib plot types through a unified API.\n", + "This notebook demonstrates each type individually, then shows how to combine\n", + "multiple types in a single subplot.\n", + "\n", + "**Plot types covered:**\n", + "`plot` · `scatter` · `bar` · `fill_between` · `errorbar` ·\n", + "`axhline` / `axvline` · `hlines` / `vlines` · combined plots · `annotate` / `text`" ] }, { @@ -15,36 +23,372 @@ "metadata": {}, "outputs": [], "source": [ - "from maxplotlib import Canvas" + "from maxplotlib import Canvas\n", + "import numpy as np\n", + "\n", + "%matplotlib inline\n", + "\n", + "rng = np.random.default_rng(42)\n", + "x = np.linspace(0, 2 * np.pi, 300)" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Backend selection\n", + "\n", + "All examples in this tutorial use the same `Canvas` API; you can switch rendering backends at any time:\n", + "\n", + "- `BACKEND = \"matplotlib\"` for static Matplotlib output\n", + "- `BACKEND = \"plotly\"` for interactive Plotly output (Jupyter-friendly)\n", + "\n", + "Most cells end with `canvas.show(backend=BACKEND)` so you can re-run the whole notebook with a different backend.\n", + "\n" ] }, { "cell_type": "code", "execution_count": null, - "id": "2", + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "# Change to \"plotly\" for interactive output\n", + "BACKEND = \"matplotlib\"" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "## 1 Line plot — `ax.plot()`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "canvas, ax = Canvas.subplots()\n", + "\n", + "ax.plot(x, np.sin(x), label=\"sin(x)\", color=\"royalblue\", linestyle=\"solid\", linewidth=2)\n", + "ax.plot(x, np.cos(x), label=\"cos(x)\", color=\"tomato\", linestyle=\"dashed\", linewidth=2)\n", + "\n", + "ax.set_xlabel(\"x\")\n", + "ax.set_ylabel(\"y\")\n", + "ax.set_title(\"Line Plot\")\n", + "ax.set_legend(True)\n", + "ax.set_grid(True)\n", + "canvas.show(backend=BACKEND)" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "## 2 Scatter plot — `ax.scatter()`\n", + "\n", + "Use `c=` to colour each point by a scalar value (requires a colormap-compatible array)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", "metadata": {}, "outputs": [], "source": [ - "c = Canvas(width=\"17cm\", ratio=0.5)\n", - "sp = c.add_subplot(\n", - " grid=False, xlabel=\"(x - 10) * 0.1\", ylabel=\"10y\", yscale=10, xshift=-10, xscale=0.1\n", + "n = 200\n", + "sx = rng.standard_normal(n)\n", + "sy = rng.standard_normal(n)\n", + "values = np.sqrt(sx**2 + sy**2) # colour by distance from origin\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.scatter(sx, sy, c=values, s=30, label=\"data points\")\n", + "ax.set_xlabel(\"x\")\n", + "ax.set_ylabel(\"y\")\n", + "ax.set_title(\"Scatter Plot — coloured by distance\")\n", + "ax.set_aspect(\"equal\")\n", + "canvas.show(backend=BACKEND)" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "## 3 Bar chart — `ax.bar()`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "categories = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\"]\n", + "values_bar = [12, 19, 15, 22, 30, 27]\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.bar(categories, values_bar, color=\"steelblue\", width=0.6, label=\"monthly sales\")\n", + "ax.set_xlabel(\"Month\")\n", + "ax.set_ylabel(\"Sales (units)\")\n", + "ax.set_title(\"Bar Chart\")\n", + "ax.set_legend(True)\n", + "# canvas.show(backend=BACKEND) # TODO: Fix this error" + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, + "source": [ + "## 4 Fill between — `ax.fill_between()`\n", + "\n", + "Useful for shading confidence bands or uncertainty regions." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [], + "source": [ + "t = np.linspace(0, 4 * np.pi, 300)\n", + "mean = np.sin(t) * np.exp(-t / 10)\n", + "upper = mean + 0.3 * (1 - t / (4 * np.pi))\n", + "lower = mean - 0.3 * (1 - t / (4 * np.pi))\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.plot(t, mean, color=\"royalblue\", label=\"mean\", linewidth=2)\n", + "ax.fill_between(t, lower, upper, alpha=0.25, color=\"royalblue\", label=\"±1 std\")\n", + "ax.set_xlabel(\"t\")\n", + "ax.set_ylabel(\"value\")\n", + "ax.set_title(\"Fill Between — Confidence Band\")\n", + "ax.set_legend(True)\n", + "canvas.show(backend=BACKEND)" + ] + }, + { + "cell_type": "markdown", + "id": "12", + "metadata": {}, + "source": [ + "## 5 Error bars — `ax.errorbar()`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13", + "metadata": {}, + "outputs": [], + "source": [ + "xm = np.linspace(0, 2 * np.pi, 12)\n", + "ym = np.sin(xm) + rng.normal(0, 0.1, len(xm))\n", + "yerr = 0.1 + 0.05 * rng.random(len(xm))\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.errorbar(xm, ym, yerr=yerr, fmt=\"o\", capsize=4, color=\"tomato\", label=\"measurements\")\n", + "ax.plot(x, np.sin(x), color=\"gray\", linestyle=\"dashed\", label=\"true sin(x)\")\n", + "ax.set_xlabel(\"x\")\n", + "ax.set_ylabel(\"y\")\n", + "ax.set_title(\"Error Bars\")\n", + "ax.set_legend(True)\n", + "ax.set_grid(True)\n", + "canvas.show(backend=BACKEND)" + ] + }, + { + "cell_type": "markdown", + "id": "14", + "metadata": {}, + "source": [ + "## 6 Reference lines — `axhline` and `axvline`\n", + "\n", + "Span the entire axis to mark thresholds or zero-crossings." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "15", + "metadata": {}, + "outputs": [], + "source": [ + "canvas, ax = Canvas.subplots()\n", + "ax.plot(x, np.sin(x), color=\"royalblue\", label=\"sin(x)\")\n", + "\n", + "ax.axhline(y=0, color=\"black\", linestyle=\"solid\", linewidth=0.8)\n", + "ax.axhline(y=0.5, color=\"green\", linestyle=\"dashed\", linewidth=1.2, label=\"y = 0.5\")\n", + "ax.axhline(y=-0.5, color=\"green\", linestyle=\"dashed\", linewidth=1.2, label=\"y = -0.5\")\n", + "ax.axvline(x=np.pi, color=\"red\", linestyle=\"dotted\", linewidth=1.5, label=\"x = π\")\n", + "\n", + "ax.set_xlabel(\"x\")\n", + "ax.set_title(\"axhline / axvline\")\n", + "ax.set_legend(True)\n", + "canvas.show(backend=BACKEND)" + ] + }, + { + "cell_type": "markdown", + "id": "16", + "metadata": {}, + "source": [ + "## 7 Segment lines — `hlines` and `vlines`\n", + "\n", + "Draw multiple horizontal or vertical line segments with explicit start/end coordinates." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "17", + "metadata": {}, + "outputs": [], + "source": [ + "canvas, ax = Canvas.subplots()\n", + "ax.plot(x, np.sin(x), color=\"lightgray\", linewidth=1)\n", + "\n", + "# Horizontal segments spanning half the x-range\n", + "ax.hlines(\n", + " y=[0.5, -0.5],\n", + " xmin=0,\n", + " xmax=np.pi,\n", + " colors=\"seagreen\",\n", + " linestyles=\"dashed\",\n", + " label=\"hlines at ±0.5\",\n", ")\n", - "sp.add_line([0, 1, 2, 3], [0, 1, 4, 9], label=\"Line 1\", layer=0)\n", - "sp.add_line(\n", - " [0, 1, 2, 3], [0, 2, 3, 4], linestyle=\"dashed\", color=\"red\", label=\"Line 2\", layer=1\n", + "\n", + "# Vertical segments at specific x positions\n", + "ax.vlines(\n", + " x=[np.pi / 2, 3 * np.pi / 2],\n", + " ymin=-1,\n", + " ymax=1,\n", + " colors=\"tomato\",\n", + " linestyles=\"dotted\",\n", + " label=\"vlines at π/2, 3π/2\",\n", ")\n", - "c.show()" + "\n", + "ax.set_xlabel(\"x\")\n", + "ax.set_title(\"hlines / vlines\")\n", + "ax.set_legend(True)\n", + "canvas.show(backend=BACKEND)" + ] + }, + { + "cell_type": "markdown", + "id": "18", + "metadata": {}, + "source": [ + "## 8 Combining plot types\n", + "\n", + "A single subplot can hold many plot types simultaneously. Here we overlay\n", + "a line, a shaded uncertainty band, and scatter points." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "19", + "metadata": {}, + "outputs": [], + "source": [ + "t = np.linspace(0, 2 * np.pi, 300)\n", + "signal = np.sin(t) * np.exp(-t / 8)\n", + "noise = rng.normal(0, 0.08, len(t))\n", + "band = 0.15 * np.exp(-t / 8)\n", + "\n", + "# Sparse measurement points\n", + "tidx = np.arange(0, len(t), 20)\n", + "tx, ty = t[tidx], (signal + noise)[tidx]\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "\n", + "ax.fill_between(\n", + " t, signal - band, signal + band, alpha=0.2, color=\"royalblue\", label=\"uncertainty\"\n", + ")\n", + "ax.plot(t, signal, color=\"royalblue\", linewidth=2, label=\"model\")\n", + "ax.scatter(tx, ty, color=\"tomato\", s=25, marker=\"o\", label=\"measurements\")\n", + "ax.axhline(y=0, color=\"gray\", linestyle=\"dashed\", linewidth=0.8)\n", + "\n", + "ax.set_xlabel(\"time\")\n", + "ax.set_ylabel(\"amplitude\")\n", + "ax.set_title(\"Combined: line + fill_between + scatter\")\n", + "ax.set_legend(True)\n", + "ax.set_grid(True)\n", + "canvas.show(backend=BACKEND)" + ] + }, + { + "cell_type": "markdown", + "id": "20", + "metadata": {}, + "source": [ + "## 9 Annotations — `ax.annotate()` and `ax.text()`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "21", + "metadata": {}, + "outputs": [], + "source": [ + "canvas, ax = Canvas.subplots()\n", + "ax.plot(x, np.sin(x), color=\"royalblue\")\n", + "\n", + "# Arrow annotation pointing to the peak\n", + "ax.annotate(\n", + " \"peak\",\n", + " xy=(np.pi / 2, 1.0),\n", + " xytext=(np.pi / 2 + 0.8, 0.7),\n", + " arrowprops=dict(arrowstyle=\"->\"),\n", + ")\n", + "\n", + "# Free-floating text label\n", + "ax.text(3 * np.pi / 2, 0.15, \"zero\\ncrossing\", ha=\"center\", fontsize=9)\n", + "\n", + "ax.set_xlabel(\"x\")\n", + "ax.set_ylabel(\"sin(x)\")\n", + "ax.set_title(\"Annotate and Text\")\n", + "canvas.show(backend=BACKEND)" + ] + }, + { + "cell_type": "markdown", + "id": "22", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "| Plot type | Method | Key kwargs |\n", + "|---|---|---|\n", + "| Line | `ax.plot(x, y)` | `color`, `linestyle`, `linewidth`, `label` |\n", + "| Scatter | `ax.scatter(x, y)` | `c`, `s`, `marker`, `label` |\n", + "| Bar | `ax.bar(x, height)` | `color`, `width`, `label` |\n", + "| Filled band | `ax.fill_between(x, y1, y2)` | `alpha`, `color`, `label` |\n", + "| Error bars | `ax.errorbar(x, y, yerr=...)` | `fmt`, `capsize`, `label` |\n", + "| Full-span h/v line | `ax.axhline(y=...)` / `ax.axvline(x=...)` | `color`, `linestyle` |\n", + "| Segment lines | `ax.hlines(y, xmin, xmax)` / `ax.vlines(x, ymin, ymax)` | `colors`, `linestyles` |\n", + "| Arrow annotation | `ax.annotate(text, xy=..., xytext=..., arrowprops=...)` | |\n", + "| Free text | `ax.text(x, y, text)` | `ha`, `fontsize` |\n", + "\n", + "You now know the full set of plot types available in maxplotlib. 🎉" ] } ], "metadata": { - "jupytext": { - "cell_metadata_filter": "-all", - "main_language": "python", - "notebook_metadata_filter": "-all" - }, "kernelspec": { - "display_name": "env_maxplotlib", + "display_name": ".venv", "language": "python", "name": "python3" }, diff --git a/tutorials/tutorial_04.ipynb b/tutorials/tutorial_04.ipynb index ef2dc8b..e7f3808 100644 --- a/tutorials/tutorial_04.ipynb +++ b/tutorials/tutorial_04.ipynb @@ -5,161 +5,127 @@ "id": "0", "metadata": {}, "source": [ - "# Tutorial 4" + "# Tutorial 04 – Styling\n", + "\n", + "maxplotlib passes matplotlib kwargs directly to the underlying renderer, so every colour, linestyle, marker, and alpha option you know from matplotlib works here too." ] }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "1", "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'\\nTutorial 4.\\n\\nAdd raw tikz code to the tikz subplot.\\n'" - ] - }, - "execution_count": 1, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ - "\"\"\"\n", - "Tutorial 4.\n", - "\n", - "Add raw tikz code to the tikz subplot.\n", - "\"\"\"" + "from maxplotlib import Canvas\n", + "import numpy as np" ] }, { - "cell_type": "code", - "execution_count": 2, + "cell_type": "markdown", "id": "2", "metadata": {}, - "outputs": [], "source": [ - "from maxplotlib import Canvas" + "## Backend selection\n", + "\n", + "All examples in this tutorial use the same `Canvas` API; you can switch rendering backends at any time:\n", + "\n", + "- `BACKEND = \"matplotlib\"` for static Matplotlib output\n", + "- `BACKEND = \"plotly\"` for interactive Plotly output (Jupyter-friendly)\n", + "\n", + "Most cells end with `canvas.show(backend=BACKEND)` so you can re-run the whole notebook with a different backend.\n", + "\n" ] }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "id": "3", "metadata": {}, "outputs": [], "source": [ - "c = Canvas(width=800, ratio=0.5)\n", - "tikz = c.add_tikzfigure(grid=False)" + "# Change to \"plotly\" for interactive output\n", + "BACKEND = \"matplotlib\"" ] }, { - "cell_type": "code", - "execution_count": 4, + "cell_type": "markdown", "id": "4", - "metadata": { - "lines_to_next_cell": 2 - }, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# Add nodes\n", - "tikz.add_node(0, 0, label=\"A\", shape=\"circle\", draw=\"black\", fill=\"blue\", layer=0)\n", - "tikz.add_node(10, 0, label=\"B\", shape=\"circle\", draw=\"black\", fill=\"blue\", layer=0)\n", - "tikz.add_node(10, 10, label=\"C\", shape=\"circle\", draw=\"black\", fill=\"blue\", layer=0)\n", - "tikz.add_node(0, 10, label=\"D\", shape=\"circle\", draw=\"black\", fill=\"blue\", layer=2)" + "metadata": {}, + "source": [ + "## 1 · Colors\n", + "\n", + "Named colors, hex strings, and RGB tuples are all accepted." ] }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "id": "5", "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# Add a line between nodes\n", - "tikz.draw(\n", - " [\"A\", \"B\", \"C\", \"D\"],\n", - " path_actions=[\"draw\", \"rounded corners\"],\n", - " fill=\"red\",\n", - " opacity=0.5,\n", - " cycle=True,\n", - " layer=1,\n", - ")" + "outputs": [], + "source": [ + "x = np.linspace(0, 2 * np.pi, 200)\n", + "\n", + "canvas, ax = Canvas.subplots(width=\"10cm\", ratio=0.6)\n", + "\n", + "ax.plot(x, np.sin(x), color=\"steelblue\", label=\"named: steelblue\")\n", + "ax.plot(x, np.sin(x - 0.5), color=\"#e74c3c\", label=\"hex: #e74c3c\")\n", + "ax.plot(x, np.sin(x - 1.0), color=(0.2, 0.7, 0.3), label=\"RGB tuple\")\n", + "\n", + "ax.set_xlabel(\"x\")\n", + "ax.set_ylabel(\"y\")\n", + "ax.set_title(\"Color options\")\n", + "ax.set_legend(True)\n", + "canvas.show(backend=BACKEND)" ] }, { - "cell_type": "code", - "execution_count": 6, + "cell_type": "markdown", "id": "6", "metadata": {}, - "outputs": [], "source": [ - "raw_tikz = r\"\"\"\n", - "\\foreach \\i in {0, 45, 90, 135, 180, 225, 270, 315} {\n", - " % Place a node at angle \\i\n", - " \\node[circle, draw, fill=green] at (\\i:3) (N\\i) {};\n", - "}\n", + "## 2 · Linestyles\n", "\n", - "% Draw lines connecting the nodes\n", - "\\foreach \\i/\\j in {0/45, 45/90, 90/135, 135/180, 180/225, 225/270, 270/315, 315/0} {\n", - " \\draw (N\\i) -- (N\\j);\n", - "}\n", - "\"\"\"" + "Use long names (`'solid'`, `'dashed'`, `'dotted'`, `'dashdot'`) or short aliases (`'-'`, `'--'`, `':'`, `'-.'`)." ] }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "id": "7", "metadata": {}, "outputs": [], "source": [ - "# TODO: Not implemented in tikzpics yet\n", - "# tikz.add_raw(raw_tikz)" + "x = np.linspace(0, 4 * np.pi, 300)\n", + "styles = [(\"solid\", \"-\"), (\"dashed\", \"--\"), (\"dotted\", \":\"), (\"dashdot\", \"-.\")]\n", + "\n", + "canvas, ax = Canvas.subplots(width=\"10cm\", ratio=0.6)\n", + "\n", + "for i, (name, ls) in enumerate(styles):\n", + " ax.plot(\n", + " x,\n", + " np.sin(x) + i * 0.4,\n", + " linestyle=ls,\n", + " linewidth=2,\n", + " color=\"steelblue\",\n", + " label=f\"{name!r} / {ls!r}\",\n", + " )\n", + "\n", + "ax.set_xlabel(\"x\")\n", + "ax.set_title(\"Linestyle comparison\")\n", + "ax.set_legend(True)\n", + "canvas.show(backend=BACKEND)" ] }, { - "cell_type": "code", - "execution_count": 9, + "cell_type": "markdown", "id": "8", "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ - "tikz.add_node(0.5, 0.5, content=\"Cube\", layer=10)" + "## 3 · Markers\n", + "\n", + "Pass `marker=` to `ax.plot` or `ax.scatter`." ] }, { @@ -167,69 +133,164 @@ "execution_count": null, "id": "9", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "\n", - "\n", - "\n", - "% --------------------------------------------- %\n", - "% Tikzfigure generated by tikzpics v0.1.1 %\n", - "% https://github.com/max-models/tikzpics %\n", - "% --------------------------------------------- %\n", - "\\begin{tikzpicture}\n", - " \n", - " % Define the layers library\n", - " \\pgfdeclarelayer{0}\n", - " \\pgfdeclarelayer{1}\n", - " \\pgfdeclarelayer{10}\n", - " \\pgfdeclarelayer{2}\n", - " \\pgfsetlayers{0,1,10,2}\n", - " \n", - " % Layer 0\n", - " \\begin{pgfonlayer}{0}\n", - " \\node[shape=circle, draw=black, fill=blue] (A) at (0, 0) {};\n", - " \\node[shape=circle, draw=black, fill=blue] (B) at (10, 0) {};\n", - " \\node[shape=circle, draw=black, fill=blue] (C) at (10, 10) {};\n", - " \\end{pgfonlayer}{0}\n", - " \n", - " % Layer 2\n", - " \\begin{pgfonlayer}{2}\n", - " \\node[shape=circle, draw=black, fill=blue] (D) at (0, 10) {};\n", - " \\end{pgfonlayer}{2}\n", - " \n", - " % Layer 1\n", - " \\begin{pgfonlayer}{1}\n", - " \\draw[path actions=['draw', 'rounded corners'], fill=red, opacity=0.5] (A) to (B) to (C) to (D) -- cycle;\n", - " \\end{pgfonlayer}{1}\n", - " \n", - " % Layer 10\n", - " \\begin{pgfonlayer}{10}\n", - " \\node (node4) at (0.5, 0.5) {Cube};\n", - " \\end{pgfonlayer}{10}\n", - "\\end{tikzpicture}\n", - "\n" - ] - } - ], - "source": [ - "# Generate the TikZ script\n", - "script = tikz.generate_tikz()\n", - "print(script)" + "outputs": [], + "source": [ + "x = np.linspace(0, 2 * np.pi, 10)\n", + "markers = [\"o\", \"s\", \"^\", \"D\", \"*\", \"x\", \"+\"]\n", + "\n", + "canvas, ax = Canvas.subplots(width=\"10cm\", ratio=0.65)\n", + "\n", + "for i, m in enumerate(markers):\n", + " ax.plot(\n", + " x,\n", + " np.sin(x) + i * 0.5,\n", + " marker=m,\n", + " linestyle=\"-\",\n", + " markersize=7,\n", + " label=f\"marker={m!r}\",\n", + " )\n", + "\n", + "ax.set_xlabel(\"x\")\n", + "ax.set_title(\"Marker comparison\")\n", + "ax.set_legend(True)\n", + "canvas.show(backend=BACKEND)" + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, + "source": [ + "## 4 · Linewidth and markersize\n", + "\n", + "Control visual weight with `linewidth=` and `markersize=`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 2 * np.pi, 40)\n", + "\n", + "canvas, ax = Canvas.subplots(width=\"10cm\", ratio=0.55)\n", + "\n", + "ax.plot(x, np.sin(x), linewidth=0.8, marker=\"o\", markersize=3, label=\"thin / small\")\n", + "ax.plot(x, np.sin(x) - 0.6, linewidth=2.5, marker=\"o\", markersize=7, label=\"medium\")\n", + "ax.plot(\n", + " x, np.sin(x) - 1.2, linewidth=4.5, marker=\"o\", markersize=12, label=\"thick / large\"\n", + ")\n", + "\n", + "ax.set_xlabel(\"x\")\n", + "ax.set_title(\"Linewidth and markersize\")\n", + "ax.set_legend(True)\n", + "canvas.show(backend=BACKEND)" + ] + }, + { + "cell_type": "markdown", + "id": "12", + "metadata": {}, + "source": [ + "## 5 · Alpha (transparency)\n", + "\n", + "`alpha=` ranges from `0` (invisible) to `1` (opaque). Useful for overlapping `fill_between` regions." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 2 * np.pi, 200)\n", + "\n", + "canvas, ax = Canvas.subplots(width=\"10cm\", ratio=0.55)\n", + "\n", + "ax.fill_between(x, np.sin(x), 0, alpha=0.7, color=\"steelblue\", label=\"alpha=0.7\")\n", + "ax.fill_between(x, np.sin(2 * x), 0, alpha=0.4, color=\"tomato\", label=\"alpha=0.4\")\n", + "ax.fill_between(x, np.sin(3 * x), 0, alpha=0.2, color=\"seagreen\", label=\"alpha=0.2\")\n", + "\n", + "ax.set_xlabel(\"x\")\n", + "ax.set_title(\"Alpha transparency\")\n", + "ax.set_legend(True)\n", + "canvas.show(backend=BACKEND)" + ] + }, + { + "cell_type": "markdown", + "id": "14", + "metadata": {}, + "source": [ + "## 6 · Combining styles – a publication-ready plot\n", + "\n", + "Combine color, linestyle, marker, linewidth, and alpha in one plot." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "15", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 2 * np.pi, 80)\n", + "noise = np.random.default_rng(0).normal(0, 0.05, len(x))\n", + "\n", + "canvas, ax = Canvas.subplots(width=\"10cm\", ratio=0.6)\n", + "\n", + "# Shaded uncertainty band\n", + "ax.fill_between(x, np.sin(x) - 0.15, np.sin(x) + 0.15, alpha=0.15, color=\"steelblue\")\n", + "\n", + "# Noisy data\n", + "ax.scatter(\n", + " x, np.sin(x) + noise, color=\"steelblue\", marker=\"o\", s=18, alpha=0.6, label=\"data\"\n", + ")\n", + "\n", + "# Clean model\n", + "ax.plot(\n", + " x,\n", + " np.sin(x),\n", + " color=\"#e74c3c\",\n", + " linestyle=\"dashed\",\n", + " linewidth=2.0,\n", + " alpha=0.9,\n", + " label=\"model\",\n", + ")\n", + "\n", + "ax.set_xlabel(\"x\")\n", + "ax.set_ylabel(\"y\")\n", + "ax.set_title(\"Publication-style plot\")\n", + "ax.set_legend(True)\n", + "canvas.show(backend=BACKEND)" + ] + }, + { + "cell_type": "markdown", + "id": "16", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "| Option | Example |\n", + "|---|---|\n", + "| Named color | `color='steelblue'` |\n", + "| Hex color | `color='#e74c3c'` |\n", + "| RGB tuple | `color=(0.2, 0.7, 0.3)` |\n", + "| Linestyle | `linestyle='dashed'` or `'--'` |\n", + "| Marker | `marker='o'` |\n", + "| Linewidth | `linewidth=2.0` |\n", + "| Markersize | `markersize=7` |\n", + "| Transparency | `alpha=0.5` |" ] } ], "metadata": { - "jupytext": { - "cell_metadata_filter": "-all", - "main_language": "python", - "notebook_metadata_filter": "-all" - }, "kernelspec": { - "display_name": "env_maxpic", + "display_name": "env_maxplotlib", "language": "python", "name": "python3" }, @@ -243,7 +304,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.13.3" + "version": "3.12.3" } }, "nbformat": 4, diff --git a/tutorials/tutorial_05.ipynb b/tutorials/tutorial_05.ipynb index 2f70552..6c16f4e 100644 --- a/tutorials/tutorial_05.ipynb +++ b/tutorials/tutorial_05.ipynb @@ -5,7 +5,9 @@ "id": "0", "metadata": {}, "source": [ - "# Tutorial 5" + "# Tutorial 05 – Axes, Ticks, Scales, and Annotations\n", + "\n", + "This tutorial covers every way to control the coordinate frame of a subplot: labels, limits, tick marks, log scales, grids, legends, and annotations." ] }, { @@ -15,42 +17,352 @@ "metadata": {}, "outputs": [], "source": [ - "import numpy as np\n", - "\n", "from maxplotlib import Canvas\n", + "import numpy as np" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Backend selection\n", + "\n", + "All examples in this tutorial use the same `Canvas` API; you can switch rendering backends at any time:\n", + "\n", + "- `BACKEND = \"matplotlib\"` for static Matplotlib output\n", + "- `BACKEND = \"plotly\"` for interactive Plotly output (Jupyter-friendly)\n", "\n", - "%load_ext autoreload\n", - "%autoreload 2" + "Most cells end with `canvas.show(backend=BACKEND)` so you can re-run the whole notebook with a different backend.\n", + "\n" ] }, { "cell_type": "code", "execution_count": null, - "id": "2", + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "# Change to \"plotly\" for interactive output\n", + "BACKEND = \"matplotlib\"" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "## 1 · Labels and title\n", + "\n", + "LaTeX strings are supported in all text arguments." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 2 * np.pi, 200)\n", + "\n", + "canvas, ax = Canvas.subplots(width=\"10cm\", ratio=0.55)\n", + "ax.plot(x, np.sin(x), color=\"steelblue\")\n", + "\n", + "ax.set_xlabel(r\"$x$ (radians)\")\n", + "ax.set_ylabel(r\"$\\sin(x)$\")\n", + "ax.set_title(r\"The sine function $f(x) = \\sin(x)$\")\n", + "canvas.show(backend=BACKEND)" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "## 2 · Axis limits" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 4 * np.pi, 300)\n", + "\n", + "canvas, ax = Canvas.subplots(width=\"10cm\", ratio=0.5)\n", + "ax.plot(x, np.sin(x), color=\"tomato\")\n", + "\n", + "# Show only the first full period\n", + "ax.set_xlim(0, 2 * np.pi)\n", + "ax.set_ylim(-1.2, 1.2)\n", + "\n", + "ax.set_xlabel(\"x\")\n", + "ax.set_ylabel(r\"$\\sin(x)$\")\n", + "ax.set_title(\"Axis limits: first period only\")\n", + "canvas.show(backend=BACKEND)" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "## 3 · Custom ticks\n", + "\n", + "Pass tick positions and optional labels to `set_xticks`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "months = [\n", + " \"Jan\",\n", + " \"Feb\",\n", + " \"Mar\",\n", + " \"Apr\",\n", + " \"May\",\n", + " \"Jun\",\n", + " \"Jul\",\n", + " \"Aug\",\n", + " \"Sep\",\n", + " \"Oct\",\n", + " \"Nov\",\n", + " \"Dec\",\n", + "]\n", + "temps = [3, 4, 7, 12, 17, 21, 23, 22, 18, 13, 7, 4]\n", + "\n", + "canvas, ax = Canvas.subplots(width=\"12cm\", ratio=0.45)\n", + "ax.plot(range(12), temps, marker=\"o\", color=\"steelblue\", linewidth=2)\n", + "\n", + "ax.set_xticks(list(range(12)), labels=months)\n", + "ax.set_ylabel(r\"Temperature ($^\\circ$C)\")\n", + "ax.set_title(\"Monthly average temperature\")\n", + "canvas.show(backend=BACKEND)" + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, + "source": [ + "## 4 · Log scale\n", + "\n", + "Use `set_yscale('log')` for data spanning several orders of magnitude. Compare linear and log in a 1×2 layout." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0.1, 5, 200)\n", + "y = np.exp(2 * x)\n", + "\n", + "canvas, (ax1, ax2) = Canvas.subplots(ncols=2, width=\"14cm\", ratio=0.4)\n", + "\n", + "ax1.plot(x, y, color=\"seagreen\")\n", + "ax1.set_xlabel(\"x\")\n", + "ax1.set_ylabel(r\"$e^{2x}$\")\n", + "ax1.set_title(\"Linear scale\")\n", + "\n", + "ax2.plot(x, y, color=\"seagreen\")\n", + "ax2.set_xlabel(\"x\")\n", + "ax2.set_yscale(\"log\")\n", + "ax2.set_title(\"Log scale\")\n", + "\n", + "canvas.suptitle(\"Linear vs log scale\")\n", + "canvas.show(backend=BACKEND)" + ] + }, + { + "cell_type": "markdown", + "id": "12", + "metadata": {}, + "source": [ + "## 5 · Grid" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13", "metadata": {}, "outputs": [], "source": [ - "c = Canvas(width=\"17cm\", ratio=0.5)\n", - "sp = c.add_subplot(grid=True, xlabel=\"x\", ylabel=\"y\")\n", + "x = np.linspace(0, 2 * np.pi, 200)\n", + "\n", + "canvas, (ax1, ax2) = Canvas.subplots(ncols=2, width=\"12cm\", ratio=0.5)\n", "\n", - "# node_a = sp.add_node(\n", - "# 0, 0, \"A\", content=\"Node A\", shape=\"circle\", draw=\"black\", fill=\"blue!20\"\n", - "# )\n", - "# node_b = sp.add_node(\n", - "# 1, 1, \"B\", content=\"Node B\", shape=\"circle\", draw=\"black\", fill=\"blue!20\"\n", - "# )\n", - "# sp.add_node(2, 2, 'B', content=\"$a^2 + b^2 = c^2$\", shape='rectangle', draw='red', fill='white', layer=1)\n", - "# sp.add_node(2, 5, 'C', shape='rectangle', draw='red', fill='red')\n", - "# last_node = sp.add_node(-1, 5, shape='rectangle', draw='red', fill='red', layer=-10)\n", + "ax1.plot(x, np.cos(x), color=\"steelblue\")\n", + "ax1.set_title(\"No grid\")\n", + "ax1.set_xlabel(\"x\")\n", "\n", - "# Add a line between nodes\n", - "# sp.draw([\"A\", \"B\"], color=\"green\", style=\"solid\", line_width=\"2\", layer=-5)\n", + "ax2.plot(x, np.cos(x), color=\"steelblue\")\n", + "ax2.set_grid(True)\n", + "ax2.set_title(\"With grid\")\n", + "ax2.set_xlabel(\"x\")\n", "\n", - "x = np.arange(0, 2 * np.pi, 0.01)\n", + "canvas.show(backend=BACKEND)" + ] + }, + { + "cell_type": "markdown", + "id": "14", + "metadata": {}, + "source": [ + "## 6 · Legend\n", + "\n", + "Enable with `set_legend(True)`. Labels come from `label=` kwargs." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "15", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 2 * np.pi, 200)\n", + "\n", + "canvas, ax = Canvas.subplots(width=\"10cm\", ratio=0.55)\n", + "ax.plot(x, np.sin(x), color=\"steelblue\", label=r\"$\\sin(x)$\")\n", + "ax.plot(x, np.cos(x), color=\"tomato\", label=r\"$\\cos(x)$\")\n", + "ax.plot(x, np.sin(2 * x), color=\"seagreen\", label=r\"$\\sin(2x)$\", linestyle=\"dashed\")\n", + "\n", + "ax.set_xlabel(\"x\")\n", + "ax.set_legend(True)\n", + "ax.set_title(\"Legend demo\")\n", + "canvas.show(backend=BACKEND)" + ] + }, + { + "cell_type": "markdown", + "id": "16", + "metadata": {}, + "source": [ + "## 7 · annotate\n", + "\n", + "Draw an arrow from a text label to a data point." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "17", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 2 * np.pi, 200)\n", "y = np.sin(x)\n", - "sp.add_line(x, y, label=r\"$\\sin(x)$\")\n", "\n", - "c.show()" + "canvas, ax = Canvas.subplots(width=\"10cm\", ratio=0.55)\n", + "ax.plot(x, y, color=\"steelblue\")\n", + "\n", + "# Annotate the maximum\n", + "ax.annotate(\n", + " r\"maximum $\\approx 1$\",\n", + " xy=(np.pi / 2, 1.0),\n", + " xytext=(np.pi / 2 + 1.0, 0.7),\n", + " arrowprops=dict(arrowstyle=\"->\", color=\"black\"),\n", + " fontsize=9,\n", + " color=\"darkred\",\n", + ")\n", + "\n", + "ax.set_xlabel(\"x\")\n", + "ax.set_ylabel(r\"$\\sin(x)$\")\n", + "ax.set_title(\"annotate demo\")\n", + "canvas.show(backend=BACKEND)" + ] + }, + { + "cell_type": "markdown", + "id": "18", + "metadata": {}, + "source": [ + "## 8 · text\n", + "\n", + "Place a text label at arbitrary data coordinates." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "19", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(-2, 2, 200)\n", + "\n", + "canvas, ax = Canvas.subplots(width=\"10cm\", ratio=0.55)\n", + "ax.plot(x, x**2, color=\"darkorange\")\n", + "\n", + "ax.text(\n", + " 0, 3.2, r\"$f(x) = x^2$\", ha=\"center\", va=\"bottom\", fontsize=12, color=\"darkorange\"\n", + ")\n", + "\n", + "ax.set_xlabel(\"x\")\n", + "ax.set_ylabel(r\"$f(x)$\")\n", + "ax.set_title(\"text demo\")\n", + "canvas.show(backend=BACKEND)" + ] + }, + { + "cell_type": "markdown", + "id": "20", + "metadata": {}, + "source": [ + "## 9 · Aspect ratio\n", + "\n", + "`set_aspect('equal')` ensures a circle looks circular." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "21", + "metadata": {}, + "outputs": [], + "source": [ + "theta = np.linspace(0, 2 * np.pi, 300)\n", + "\n", + "canvas, ax = Canvas.subplots(width=\"7cm\", ratio=1.0)\n", + "ax.plot(np.cos(theta), np.sin(theta), color=\"steelblue\", linewidth=2)\n", + "ax.set_aspect(\"equal\")\n", + "ax.set_title(\"Circle with equal aspect ratio\")\n", + "ax.set_xlabel(\"x\")\n", + "ax.set_ylabel(\"y\")\n", + "canvas.show(backend=BACKEND)" + ] + }, + { + "cell_type": "markdown", + "id": "22", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "| Method | Purpose |\n", + "|---|---|\n", + "| `set_xlabel` / `set_ylabel` | Axis labels (LaTeX OK) |\n", + "| `set_title` | Subplot title |\n", + "| `set_xlim` / `set_ylim` | Axis range |\n", + "| `set_xticks(pos, labels=)` | Custom tick marks |\n", + "| `set_yscale('log')` | Logarithmic scale |\n", + "| `set_grid(True)` | Background grid |\n", + "| `set_legend(True)` | Auto legend |\n", + "| `annotate(...)` | Arrow annotation |\n", + "| `text(x, y, s)` | Free text label |\n", + "| `set_aspect('equal')` | Equal x/y scaling |" ] } ], diff --git a/tutorials/tutorial_06.ipynb b/tutorials/tutorial_06.ipynb index 514274a..6902623 100644 --- a/tutorials/tutorial_06.ipynb +++ b/tutorials/tutorial_06.ipynb @@ -5,7 +5,9 @@ "id": "0", "metadata": {}, "source": [ - "# Tutorial 6" + "# Tutorial 06 – Layers\n", + "\n", + "**Layers** let you assign each piece of data to a numbered layer. You can then render a subset of layers — e.g. to reveal a derivation step by step, or to produce separate PDF overlays from a single source file." ] }, { @@ -16,32 +18,265 @@ "outputs": [], "source": [ "from maxplotlib import Canvas\n", - "import numpy as np\n", + "import numpy as np" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Backend selection\n", "\n", - "%load_ext autoreload\n", - "%autoreload 2" + "All examples in this tutorial use the same `Canvas` API; you can switch rendering backends at any time:\n", + "\n", + "- `BACKEND = \"matplotlib\"` for static Matplotlib output\n", + "- `BACKEND = \"plotly\"` for interactive Plotly output (Jupyter-friendly)\n", + "\n", + "Most cells end with `canvas.show(backend=BACKEND)` so you can re-run the whole notebook with a different backend.\n", + "\n" ] }, { "cell_type": "code", "execution_count": null, - "id": "2", + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "# Change to \"plotly\" for interactive output\n", + "BACKEND = \"matplotlib\"" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "## 1 · Assigning data to layers\n", + "\n", + "Pass `layer=` to any plot call. Layers are just integer tags — the default is `0`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 2 * np.pi, 200)\n", + "\n", + "canvas, ax = Canvas.subplots(width=\"10cm\", ratio=0.55)\n", + "\n", + "ax.plot(x, np.sin(x), color=\"steelblue\", label=r\"$\\sin(x)$\", layer=0)\n", + "ax.plot(x, np.cos(x), color=\"tomato\", label=r\"$\\cos(x)$\", layer=1)\n", + "ax.plot(\n", + " x,\n", + " np.sin(x) * np.cos(x),\n", + " color=\"seagreen\",\n", + " label=r\"$\\sin(x)\\cos(x)$\",\n", + " linestyle=\"dashed\",\n", + " layer=2,\n", + ")\n", + "\n", + "ax.set_xlabel(\"x\")\n", + "ax.set_legend(True)\n", + "ax.set_title(\"Three curves on three layers\")\n", + "canvas.show(backend=BACKEND) # renders all layers by default" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "## 2 · Rendering specific layers\n", + "\n", + "Pass `layers=[...]` to `canvas.show()` or `canvas.savefig()` to render only a subset of layers." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 2 * np.pi, 200)\n", + "\n", + "canvas, ax = Canvas.subplots(width=\"10cm\", ratio=0.55)\n", + "\n", + "ax.plot(x, np.sin(x), color=\"steelblue\", label=r\"$\\sin(x)$\", layer=0)\n", + "ax.plot(x, np.cos(x), color=\"tomato\", label=r\"$\\cos(x)$\", layer=1)\n", + "ax.plot(\n", + " x,\n", + " np.sin(x) * np.cos(x),\n", + " color=\"seagreen\",\n", + " label=r\"$\\sin(x)\\cos(x)$\",\n", + " linestyle=\"dashed\",\n", + " layer=2,\n", + ")\n", + "\n", + "ax.set_xlabel(\"x\")\n", + "ax.set_legend(True)\n", + "\n", + "print(\"--- Layer 0 only ---\")\n", + "ax.set_title(\"Layer 0 only\")\n", + "\n", + "canvas.show(backend=BACKEND, layers=[0])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8", + "metadata": {}, + "outputs": [], + "source": [ + "# Same canvas — now show layers 0 and 1 together\n", + "ax.set_title(\"Layers 0 and 1\")\n", + "\n", + "canvas.show(backend=BACKEND, layers=[0, 1])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "# All layers\n", + "ax.set_title(\"All layers\")\n", + "\n", + "canvas.show(backend=BACKEND, layers=[0, 1, 2])" + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, + "source": [ + "## 3 · Saving layer-by-layer\n", + "\n", + "`savefig` with `layer_by_layer=True` writes one file per layer (e.g. `fig_layer0.pdf`, `fig_layer1.pdf`, …). This is ideal for building slide animations or LaTeX overlays." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", "metadata": {}, "outputs": [], "source": [ - "c = Canvas(width=\"17cm\", ratio=0.5)\n", - "sp = c.add_subplot(grid=False, xlabel=\"x\", ylabel=\"y\")\n", - "# sp.add_line([0, 1, 2, 3], [0, 1, 4, 9], label=\"Line 1\",layer=1)\n", - "data = np.random.random((10, 10))\n", - "sp.add_imshow(data, extent=[1, 10, 1, 20], layer=1)\n", + "# Demonstration — not executed to avoid writing files during tutorial\n", + "#\n", + "# canvas.savefig('fig.pdf', layer_by_layer=True)\n", + "#\n", + "# Produces:\n", + "# fig_layer0.pdf (layer 0 only)\n", + "# fig_layer1.pdf (layers 0–1)\n", + "# fig_layer2.pdf (layers 0–2, i.e. all)\n", + "#\n", + "# Each file is a cumulative reveal, suitable for \\includegraphics[<1->]{fig_layer0}\n", + "# in Beamer." + ] + }, + { + "cell_type": "markdown", + "id": "12", + "metadata": {}, + "source": [ + "## 4 · Use case: progressively revealing a derivation\n", + "\n", + "Build a plot where each layer adds the next step of a mathematical derivation." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 2 * np.pi, 300)\n", + "\n", + "canvas, ax = Canvas.subplots(width=\"11cm\", ratio=0.6)\n", + "\n", + "# Layer 0: raw data (noisy sine)\n", + "rng = np.random.default_rng(42)\n", + "y_data = np.sin(x) + rng.normal(0, 0.15, len(x))\n", + "ax.scatter(x, y_data, color=\"gray\", s=8, alpha=0.5, label=\"measured data\", layer=0)\n", + "\n", + "# Layer 1: true function\n", + "ax.plot(x, np.sin(x), color=\"steelblue\", linewidth=2, label=r\"true: $\\sin(x)$\", layer=1)\n", + "\n", + "# Layer 2: envelope\n", + "ax.fill_between(\n", + " x,\n", + " np.sin(x) - 0.15,\n", + " np.sin(x) + 0.15,\n", + " alpha=0.2,\n", + " color=\"steelblue\",\n", + " label=r\"$\\pm 0.15$ envelope\",\n", + " layer=2,\n", + ")\n", + "\n", + "ax.set_xlabel(\"x\")\n", + "ax.set_ylabel(\"y\")\n", + "ax.set_legend(True)\n", + "\n", + "# Step 1: only the data cloud\n", + "ax.set_title(\"Step 1 – raw data\")\n", + "canvas.show(backend=BACKEND, layers=[0])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "14", + "metadata": {}, + "outputs": [], + "source": [ + "# Step 2: add the true curve\n", + "ax.set_title(\"Step 2 – add true function\")\n", + "canvas.show(backend=BACKEND, layers=[0, 1])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "15", + "metadata": {}, + "outputs": [], + "source": [ + "# Step 3: add the uncertainty envelope\n", + "ax.set_title(\"Step 3 – add uncertainty envelope\")\n", + "canvas.show(backend=BACKEND, layers=[0, 1, 2])" + ] + }, + { + "cell_type": "markdown", + "id": "16", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "| Concept | How |\n", + "|---|---|\n", + "| Assign to layer | `ax.plot(..., layer=1)` |\n", + "| Render subset | `canvas.show(layers=[0, 1])` |\n", + "| Save all layers | `canvas.savefig('fig.pdf', layer_by_layer=True)` |\n", + "| Default layer | `0` (omit `layer=` and it goes to layer 0) |\n", "\n", - "c.show()" + "Layers make it easy to build slide-deck animations or incremental pedagogical figures from a single Python file." ] } ], "metadata": { "kernelspec": { - "display_name": "env_maxpic", + "display_name": ".venv", "language": "python", "name": "python3" }, @@ -55,7 +290,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.13.3" + "version": "3.12.3" } }, "nbformat": 4, diff --git a/tutorials/tutorial_07_tikz.ipynb b/tutorials/tutorial_07_tikz.ipynb new file mode 100644 index 0000000..078e321 --- /dev/null +++ b/tutorials/tutorial_07_tikz.ipynb @@ -0,0 +1,773 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Tutorial 07 — TikZ Backend\n", + "\n", + "**TikZ** is the de-facto standard for drawing in LaTeX documents. \n", + "maxplotlib can render your figures as native TikZ code via the `tikzfigure` backend,\n", + "which wraps the [`tikzfigure`](https://github.com/max-models/tikzfigure) Python package.\n", + "\n", + "This tutorial covers **two complementary workflows**:\n", + "\n", + "| Workflow | When to use |\n", + "|---|---|\n", + "| **Canvas → TikZ** | Quick way to turn data plots into LaTeX-ready TikZ code |\n", + "| **`tikzfigure` API directly** | Full control — nodes, shapes, annotations, arcs, colours … |\n", + "\n", + "**Prerequisites**\n", + "\n", + "```bash\n", + "pip install tikzfigure\n", + "```\n", + "\n", + "To actually *render* the figure (not just generate code) you also need `pdflatex` installed on your system." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import tikzfigure as tz\n", + "from maxplotlib import Canvas" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "---\n", + "## Part 1 — Canvas → TikZ\n", + "\n", + "The fastest path: build a plot with the standard Canvas API, then pass `backend='tikzfigure'` to get a `TikzFigure` object back." + ] + }, + { + "cell_type": "markdown", + "id": "3", + "metadata": {}, + "source": [ + "### 1.1 Basic usage" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 2 * np.pi, 60)\n", + "\n", + "canvas, ax = Canvas.subplots(width=\"10cm\", ratio=0.6)\n", + "ax.plot(x, np.sin(x), label=\"sin\", color=\"steelblue\", line_width=1.5)\n", + "ax.plot(x, np.cos(x), label=\"cos\", color=\"tomato\", line_width=1.2)\n", + "ax.set_xlabel(\"x\")\n", + "ax.set_ylabel(\"y\")\n", + "ax.set_title(\"Trigonometric functions\")\n", + "\n", + "# backend='tikzfigure' returns a TikzFigure object\n", + "tikz = canvas.plot(backend=\"tikzfigure\")\n", + "print(type(tikz))" + ] + }, + { + "cell_type": "markdown", + "id": "5", + "metadata": {}, + "source": [ + "### Plotly preview\n", + "\n", + "Before exporting to TikZ, you can preview the same `Canvas` interactively in a notebook using the Plotly backend:\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], + "source": [ + "canvas.show(backend=\"plotly\")" + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": {}, + "source": [ + "### 1.2 Inspecting the generated LaTeX\n", + "\n", + "`tikz.generate_tikz()` returns the raw LaTeX source string. \n", + "Each data line becomes a `\\draw` command connecting coordinate pairs." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8", + "metadata": {}, + "outputs": [], + "source": [ + "code = tikz.generate_tikz()\n", + "print(code)" + ] + }, + { + "cell_type": "markdown", + "id": "9", + "metadata": {}, + "source": [ + "### 1.2.1 Checking explicit width and height\n", + "\n", + "When you set both `width=` and `ratio=`, the TikZ export now writes explicit pgfplots dimensions.\n", + "This is useful when you want a tall figure for a column-sized layout in LaTeX." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "10", + "metadata": {}, + "outputs": [], + "source": [ + "canvas_ratio2, ax_ratio2 = Canvas.subplots(width=\"10cm\", ratio=2)\n", + "ax_ratio2.plot(x, np.exp(-x / np.pi), color=\"purple\", line_width=1.5)\n", + "ax_ratio2.set_title(\"ratio = 2 export\")\n", + "\n", + "tikz_ratio2 = canvas_ratio2.plot(backend=\"tikzfigure\")\n", + "ratio2_code = tikz_ratio2.generate_tikz()\n", + "\n", + "for line in ratio2_code.splitlines():\n", + " if \"nextgroupplot\" in line:\n", + " print(line.strip())\n", + " break\n", + "\n", + "# Expected: width=10cm and height=20cm in the \\nextgroupplot options" + ] + }, + { + "cell_type": "markdown", + "id": "11", + "metadata": {}, + "source": [ + "### 1.3 TikZ-specific kwargs\n", + "\n", + "The TikZ backend passes extra keyword arguments straight to `tikzfigure.draw()`. \n", + "Use **`line_width=`** (not matplotlib's `linewidth=`) to control stroke thickness." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12", + "metadata": {}, + "outputs": [], + "source": [ + "canvas2, ax2 = Canvas.subplots(width=\"10cm\", ratio=0.5)\n", + "ax2.plot(x, np.sin(x), color=\"navy\", line_width=0.5, label=\"thin\")\n", + "ax2.plot(x, np.sin(x) + 0.5, color=\"steelblue\", line_width=1.5, label=\"medium\")\n", + "ax2.plot(x, np.sin(x) + 1.0, color=\"royalblue\", line_width=3.0, label=\"thick\")\n", + "ax2.set_xlabel(\"x\")\n", + "ax2.set_title(\"Line width comparison\")\n", + "\n", + "tikz2 = canvas2.plot(backend=\"tikzfigure\")\n", + "print(tikz2.generate_tikz())" + ] + }, + { + "cell_type": "markdown", + "id": "13", + "metadata": {}, + "source": [ + "### 1.4 Layer-aware TikZ output\n", + "\n", + "Assign data to layers with `layer=N`. \n", + "The TikZ backend respects the layer filter — useful for generating incremental reveal figures (e.g. in Beamer)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "14", + "metadata": {}, + "outputs": [], + "source": [ + "canvas3, ax3 = Canvas.subplots(width=\"10cm\", ratio=0.55)\n", + "ax3.plot(x, np.sin(x), color=\"steelblue\", line_width=1.5, layer=0, label=\"sin\")\n", + "ax3.plot(x, np.cos(x), color=\"tomato\", line_width=1.5, layer=1, label=\"cos\")\n", + "ax3.plot(\n", + " x, np.sin(x) * np.cos(x), color=\"seagreen\", line_width=1.0, layer=2, label=\"sin·cos\"\n", + ")\n", + "\n", + "# All layers available on the canvas\n", + "print(\"Available layers:\", canvas3.layers)\n", + "\n", + "# Render only layer 0 — one \\draw command\n", + "tikz_l0 = canvas3.plot(backend=\"tikzfigure\", layers=[0])\n", + "print(\"\\n--- Layer 0 only ---\")\n", + "print(f\"\\\\draw count: {tikz_l0.generate_tikz().count(chr(92) + 'draw')}\")\n", + "\n", + "# Render layers 0 and 1\n", + "tikz_l01 = canvas3.plot(backend=\"tikzfigure\", layers=[0, 1])\n", + "print(\"\\n--- Layers 0 & 1 ---\")\n", + "print(f\"\\\\draw count: {tikz_l01.generate_tikz().count(chr(92) + 'draw')}\")" + ] + }, + { + "cell_type": "markdown", + "id": "15", + "metadata": {}, + "source": [ + "### 1.5 Saving TikZ code to a file\n", + "\n", + "You can embed the generated code directly in a LaTeX document:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "16", + "metadata": {}, + "outputs": [], + "source": [ + "tikz_all = canvas3.plot(backend=\"tikzfigure\")\n", + "\n", + "with open(\"figure.tex\", \"w\") as f:\n", + " f.write(tikz_all.generate_tikz())\n", + "\n", + "print(\"Saved figure.tex\")\n", + "\n", + "# In your LaTeX document:\n", + "# \\input{figure.tex}\n", + "# or wrap it:\n", + "# \\begin{figure}[h]\n", + "# \\centering\n", + "# \\input{figure.tex}\n", + "# \\caption{My caption}\n", + "# \\end{figure}" + ] + }, + { + "cell_type": "markdown", + "id": "17", + "metadata": {}, + "source": [ + "### 1.6 Rendering the figure (requires `pdflatex`)\n", + "\n", + "If `pdflatex` is installed, `tikz.show()` compiles the code and opens the PDF:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "18", + "metadata": {}, + "outputs": [], + "source": [ + "# Requires pdflatex:\n", + "tikz_all.show(transparent=False)" + ] + }, + { + "cell_type": "markdown", + "id": "19", + "metadata": {}, + "source": [ + "### 1.7 Canvas → TikZ limitations\n", + "\n", + "| Feature | Supported? |\n", + "|---|---|\n", + "| Line plots (`ax.plot`) | ✅ |\n", + "| Layer filtering | ✅ |\n", + "| `line_width=` kwarg | ✅ |\n", + "| Multiple subplots | ❌ (raises `NotImplementedError`) |\n", + "| `ax.scatter`, `ax.bar` | ❌ (silently ignored) |\n", + "| `ax.fill_between` | ❌ |\n", + "| Axis labels / titles | ❌ (TikZ has no axis frame by default) |\n", + "\n", + "For anything beyond line plots, use the `tikzfigure` API directly (Part 2 below)." + ] + }, + { + "cell_type": "markdown", + "id": "20", + "metadata": {}, + "source": [ + "---\n", + "## Part 2 — The `tikzfigure` API\n", + "\n", + "The `tikzfigure` package gives you a full Python interface to TikZ primitives.\n", + "You build figures by adding nodes, paths, shapes, and annotations, then\n", + "call `generate_tikz()` (or `show()`) to obtain the output.\n", + "\n", + "```python\n", + "import tikzfigure as tz\n", + "tf = tz.TikzFigure()\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "21", + "metadata": {}, + "source": [ + "### 2.1 Drawing paths with `draw()`\n", + "\n", + "`tf.draw(nodes, ...)` produces a `\\draw` path through a list of `(x, y)` coordinates." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "22", + "metadata": {}, + "outputs": [], + "source": [ + "tf = tz.TikzFigure()\n", + "\n", + "x = np.linspace(0, 2 * np.pi, 60)\n", + "sin_nodes = [(float(xi), float(np.sin(xi))) for xi in x]\n", + "cos_nodes = [(float(xi), float(np.cos(xi))) for xi in x]\n", + "\n", + "tf.draw(sin_nodes, color=\"steelblue\", line_width=1.5)\n", + "tf.draw(cos_nodes, color=\"tomato\", line_width=1.2)\n", + "\n", + "print(tf.generate_tikz())" + ] + }, + { + "cell_type": "markdown", + "id": "23", + "metadata": {}, + "source": [ + "### 2.2 Straight line segments with `line()`\n", + "\n", + "`tf.line(start, end, ...)` is a convenience wrapper for a two-point path. \n", + "The `arrows` parameter adds arrowheads." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "24", + "metadata": {}, + "outputs": [], + "source": [ + "tf2 = tz.TikzFigure()\n", + "\n", + "# Baseline\n", + "tf2.line((0, 0), (2 * np.pi, 0), color=\"gray\", dash_pattern=\"on 3pt off 3pt\")\n", + "\n", + "# Arrow showing direction\n", + "tf2.line((0, -1.2), (0, 1.2), color=\"black\", arrows=\"->\", line_width=0.8)\n", + "tf2.line((-0.2, 0), (2 * np.pi + 0.2, 0), color=\"black\", arrows=\"->\", line_width=0.8)\n", + "\n", + "# The curve\n", + "tf2.draw(sin_nodes, color=\"steelblue\", line_width=1.5)\n", + "\n", + "print(tf2.generate_tikz())" + ] + }, + { + "cell_type": "markdown", + "id": "25", + "metadata": {}, + "source": [ + "### 2.3 Rectangles, circles, and arcs" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "26", + "metadata": {}, + "outputs": [], + "source": [ + "tf3 = tz.TikzFigure()\n", + "\n", + "# Bounding rectangle (coordinate space for context)\n", + "tf3.rectangle((0, -1.2), (2 * np.pi, 1.2), draw=\"gray!40\", fill=\"gray!5\")\n", + "\n", + "# Circle at the origin\n", + "tf3.circle((0, 0), radius=0.15, fill=\"red!60\", draw=\"red\")\n", + "\n", + "# Circle at peak of sine\n", + "tf3.circle((np.pi / 2, 1.0), radius=0.12, fill=\"steelblue\", draw=\"none\")\n", + "\n", + "# Arc (quarter circle)\n", + "tf3.arc(\n", + " (0.4, 0),\n", + " start_angle=0,\n", + " end_angle=90,\n", + " radius=0.4,\n", + " draw=\"green!60!black\",\n", + " line_width=1.0,\n", + ")\n", + "\n", + "# The curve on top\n", + "tf3.draw(sin_nodes, color=\"steelblue\", line_width=1.5)\n", + "\n", + "print(tf3.generate_tikz())" + ] + }, + { + "cell_type": "markdown", + "id": "27", + "metadata": {}, + "source": [ + "### 2.4 Nodes — text labels and markers\n", + "\n", + "`add_node()` places a text label (optionally inside a shape) at an `(x, y)` position." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "28", + "metadata": {}, + "outputs": [], + "source": [ + "tf4 = tz.TikzFigure()\n", + "tf4.draw(sin_nodes, color=\"steelblue\", line_width=1.5)\n", + "\n", + "# Plain text label\n", + "tf4.add_node(np.pi / 2, 1.15, content=r\"$\\max$\", color=\"steelblue\")\n", + "\n", + "# Boxed label\n", + "tf4.add_node(\n", + " 3 * np.pi / 2,\n", + " -1.15,\n", + " content=r\"$\\min$\",\n", + " shape=\"rectangle\",\n", + " fill=\"tomato!20\",\n", + " draw=\"tomato\",\n", + " inner_sep=\"2pt\",\n", + ")\n", + "\n", + "# Circle marker at zero-crossing\n", + "tf4.add_node(\n", + " np.pi, 0, shape=\"circle\", fill=\"white\", draw=\"steelblue\", minimum_size=\"0.18cm\"\n", + ")\n", + "\n", + "print(tf4.generate_tikz())" + ] + }, + { + "cell_type": "markdown", + "id": "29", + "metadata": {}, + "source": [ + "### 2.5 Custom colours with `colorlet()`\n", + "\n", + "TikZ colour mixing syntax (`blue!70!white`) lets you define reusable named colours." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "30", + "metadata": {}, + "outputs": [], + "source": [ + "tf5 = tz.TikzFigure()\n", + "\n", + "# Define named colours\n", + "tf5.colorlet(\"myblue\", \"blue!70!white\")\n", + "tf5.colorlet(\"myred\", \"red!80!black\")\n", + "tf5.colorlet(\"myfill\", \"blue!10!white\")\n", + "\n", + "# Use them in draw calls\n", + "tf5.draw(sin_nodes, color=\"myblue\", line_width=1.5)\n", + "tf5.draw(cos_nodes, color=\"myred\", line_width=1.5)\n", + "\n", + "# Filled polygon using the fill colour\n", + "closed_nodes = sin_nodes + [(float(x[-1]), 0.0), (float(x[0]), 0.0)]\n", + "tf5.draw(closed_nodes, fill=\"myfill\", draw=\"none\", cycle=True)\n", + "\n", + "print(tf5.generate_tikz())" + ] + }, + { + "cell_type": "markdown", + "id": "31", + "metadata": {}, + "source": [ + "### 2.6 Filled paths and patterns\n", + "\n", + "Pass `fill=` and/or `pattern=` to `draw()` to create shaded regions." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "32", + "metadata": {}, + "outputs": [], + "source": [ + "tf6 = tz.TikzFigure()\n", + "\n", + "# Shaded area under sin curve (closed path)\n", + "area_nodes = sin_nodes + [(float(x[-1]), 0.0), (float(x[0]), 0.0)]\n", + "tf6.draw(area_nodes, fill=\"steelblue!20\", draw=\"none\", cycle=True)\n", + "\n", + "# Hatched region using a pattern\n", + "cos_area = cos_nodes + [(float(x[-1]), 0.0), (float(x[0]), 0.0)]\n", + "tf6.draw(\n", + " cos_area,\n", + " pattern=\"north east lines\",\n", + " pattern_color=\"tomato\",\n", + " draw=\"none\",\n", + " cycle=True,\n", + ")\n", + "\n", + "# Curves on top\n", + "tf6.draw(sin_nodes, color=\"steelblue\", line_width=1.5)\n", + "tf6.draw(cos_nodes, color=\"tomato\", line_width=1.2)\n", + "\n", + "print(tf6.generate_tikz())" + ] + }, + { + "cell_type": "markdown", + "id": "33", + "metadata": {}, + "source": [ + "### 2.7 Layers in `TikzFigure`\n", + "\n", + "The `layer=` parameter on every drawing call controls render order.\n", + "Lower-numbered layers are drawn first (behind), higher layers on top." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "34", + "metadata": {}, + "outputs": [], + "source": [ + "tf7 = tz.TikzFigure()\n", + "\n", + "# layer 0: background fill (drawn first)\n", + "tf7.rectangle((0, -1.2), (2 * np.pi, 1.2), fill=\"gray!8\", draw=\"gray!30\", layer=0)\n", + "\n", + "# layer 1: shaded area\n", + "area = sin_nodes + [(float(x[-1]), 0), (float(x[0]), 0)]\n", + "tf7.draw(area, fill=\"steelblue!25\", draw=\"none\", cycle=True, layer=1)\n", + "\n", + "# layer 2: the curve (drawn last, on top)\n", + "tf7.draw(sin_nodes, color=\"steelblue\", line_width=2.0, layer=2)\n", + "tf7.add_node(np.pi / 2, 1.15, content=r\"$\\sin(x)$\", color=\"steelblue\", layer=2)\n", + "\n", + "print(tf7.generate_tikz())" + ] + }, + { + "cell_type": "markdown", + "id": "35", + "metadata": {}, + "source": [ + "### 2.8 Escaping to raw TikZ code\n", + "\n", + "For anything not yet covered by the API, use `add_raw()` to inject verbatim TikZ." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "36", + "metadata": {}, + "outputs": [], + "source": [ + "tf8 = tz.TikzFigure()\n", + "tf8.draw(sin_nodes, color=\"steelblue\", line_width=1.5)\n", + "\n", + "# Inject custom TikZ — a dashed grid line\n", + "tf8.add_raw(r\"\\draw[gray!40, dashed] (0, 0) -- (6.28, 0);\")\n", + "\n", + "# Annotation with arrow using raw TikZ\n", + "tf8.add_raw(\n", + " r\"\\draw[->, gray] (1.0, 0.6) -- (1.57, 1.0) node[right, font=\\small] {peak};\"\n", + ")\n", + "\n", + "print(tf8.generate_tikz())" + ] + }, + { + "cell_type": "markdown", + "id": "37", + "metadata": {}, + "source": [ + "### 2.9 Putting it all together — a complete figure\n", + "\n", + "Combine paths, shapes, nodes, and colours into a single publication-ready figure." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "38", + "metadata": {}, + "outputs": [], + "source": [ + "tf_final = tz.TikzFigure(figsize=(12, 7))\n", + "\n", + "# --- colours ---\n", + "tf_final.colorlet(\"cblue\", \"blue!65!white\")\n", + "tf_final.colorlet(\"cred\", \"red!75!black\")\n", + "\n", + "# --- background ---\n", + "tf_final.rectangle((0, -1.3), (2 * np.pi, 1.3), fill=\"gray!5\", draw=\"gray!30\")\n", + "\n", + "# --- zero axis ---\n", + "tf_final.line((0, 0), (2 * np.pi, 0), color=\"gray!60\", dash_pattern=\"on 2pt off 2pt\")\n", + "\n", + "# --- shaded area between curves ---\n", + "# approximate: shade where sin > cos (first half)\n", + "x_half = x[x <= np.pi]\n", + "upper = np.sin(x_half)\n", + "lower = np.cos(x_half)\n", + "region = [(float(xi), float(u)) for xi, u in zip(x_half, upper)] + [\n", + " (float(xi), float(l)) for xi, l in zip(reversed(x_half), reversed(lower))\n", + "]\n", + "tf_final.draw(region, fill=\"cblue!20\", draw=\"none\", cycle=True)\n", + "\n", + "# --- curves ---\n", + "tf_final.draw(sin_nodes, color=\"cblue\", line_width=1.8)\n", + "tf_final.draw(cos_nodes, color=\"cred\", line_width=1.5)\n", + "\n", + "# --- markers at key points ---\n", + "tf_final.circle((np.pi / 2, 1.0), radius=0.08, fill=\"cblue\", draw=\"none\")\n", + "tf_final.circle((np.pi, 0.0), radius=0.08, fill=\"cblue\", draw=\"none\")\n", + "tf_final.circle((0, 1.0), radius=0.08, fill=\"cred\", draw=\"none\")\n", + "\n", + "# --- labels ---\n", + "tf_final.add_node(\n", + " np.pi / 2 + 0.3, 1.05, content=r\"$\\sin(x)$\", color=\"cblue\", anchor=\"west\"\n", + ")\n", + "tf_final.add_node(0.2, 1.1, content=r\"$\\cos(x)$\", color=\"cred\", anchor=\"west\")\n", + "\n", + "# --- save and show ---\n", + "with open(\"complete_figure.tex\", \"w\") as f:\n", + " f.write(tf_final.generate_tikz())\n", + "print(\"Saved complete_figure.tex\")\n", + "print()\n", + "print(tf_final.generate_tikz())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "39", + "metadata": {}, + "outputs": [], + "source": [ + "# Renders to PDF (requires pdflatex):\n", + "tf_final.show()" + ] + }, + { + "cell_type": "markdown", + "id": "40", + "metadata": {}, + "source": [ + "### 2.10 Embedding in a LaTeX document\n", + "\n", + "The generated code is a standalone `tikzpicture` environment. \n", + "Drop it into any LaTeX document:\n", + "\n", + "```latex\n", + "\\usepackage{tikz}\n", + "\n", + "\\begin{figure}[h]\n", + " \\centering\n", + " \\input{complete_figure.tex}\n", + " \\caption{Trigonometric functions with shaded region.}\n", + " \\label{fig:trig}\n", + "\\end{figure}\n", + "```\n", + "\n", + "Or compile a standalone PDF with `tikzfigure`'s `generate_standalone()` method:\n", + "\n", + "```python\n", + "standalone_src = tf_final.generate_standalone()\n", + "with open('standalone.tex', 'w') as f:\n", + " f.write(standalone_src)\n", + "# Then: pdflatex standalone.tex\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "41", + "metadata": {}, + "source": [ + "---\n", + "## Summary\n", + "\n", + "### Canvas → TikZ workflow\n", + "```python\n", + "canvas, ax = Canvas.subplots(width='10cm', ratio=0.6)\n", + "ax.plot(x, y, color='steelblue', line_width=1.5)\n", + "tikz = canvas.plot(backend='tikzfigure')\n", + "print(tikz.generate_tikz()) # inspect LaTeX\n", + "tikz.show() # render (needs pdflatex)\n", + "```\n", + "\n", + "### Direct `tikzfigure` API — key methods\n", + "\n", + "| Method | Purpose |\n", + "|---|---|\n", + "| `tf.draw(nodes, color=, line_width=, fill=, ...)` | Path through coordinate list |\n", + "| `tf.line(start, end, arrows='->', ...)` | Straight line segment |\n", + "| `tf.rectangle(corner1, corner2, fill=, draw=, ...)` | Rectangle |\n", + "| `tf.circle(center, radius, fill=, ...)` | Circle |\n", + "| `tf.arc(start, start_angle, end_angle, radius, ...)` | Arc |\n", + "| `tf.add_node(x, y, content=, shape=, fill=, ...)` | Labelled node |\n", + "| `tf.colorlet(name, color_expr)` | Define named colour |\n", + "| `tf.add_raw(tikz_code)` | Inject verbatim TikZ |\n", + "| `tf.generate_tikz()` | Return LaTeX string |\n", + "| `tf.show()` | Compile + display (needs `pdflatex`) |\n", + "\n", + "### TikZ colour syntax cheatsheet\n", + "| Expression | Meaning |\n", + "|---|---|\n", + "| `'red'`, `'blue'`, `'green'` | Standard colours |\n", + "| `'blue!70!white'` | 70% blue + 30% white |\n", + "| `'red!80!black'` | 80% red + 20% black |\n", + "| `'blue!50!red'` | 50% blend |\n", + "| `'gray!20'` | 20% gray (80% white) |" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/tutorial_07_tikzpics.ipynb b/tutorials/tutorial_07_tikzpics.ipynb deleted file mode 100644 index d3bc587..0000000 --- a/tutorials/tutorial_07_tikzpics.ipynb +++ /dev/null @@ -1,62 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "0", - "metadata": {}, - "source": [ - "# Tutorial 6" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1", - "metadata": {}, - "outputs": [], - "source": [ - "from maxplotlib import Canvas\n", - "\n", - "%load_ext autoreload\n", - "%autoreload 2" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2", - "metadata": {}, - "outputs": [], - "source": [ - "c = Canvas(width=\"17cm\", ratio=0.5)\n", - "sp = c.add_subplot(grid=False, xlabel=\"x\", ylabel=\"y\")\n", - "sp.add_line([0, 1, 2, 3], [0, 1, 0, 2], label=\"Line 1\", layer=1, line_width=2.0)\n", - "\n", - "\n", - "# TODO: Uncomment if pdflatex is installed\n", - "# c.show(backend=\"tikzpics\")" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env_maxpic", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.13.3" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/tutorials/tutorial_08_plotly.ipynb b/tutorials/tutorial_08_plotly.ipynb new file mode 100644 index 0000000..2ecf2ef --- /dev/null +++ b/tutorials/tutorial_08_plotly.ipynb @@ -0,0 +1,443 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Tutorial 08 – Plotly Backend\n", + "\n", + "The **Plotly backend** renders your maxplotlib canvas as an interactive `plotly.graph_objects.Figure`. Unlike the default matplotlib output, Plotly figures:\n", + "\n", + "- Are **interactive** in Jupyter: zoom, pan, hover for values, toggle traces.\n", + "- Can be **exported as standalone HTML** files that work in any browser—no Python or server required.\n", + "- Support multi-subplot layouts.\n", + "\n", + "### When to use Plotly vs matplotlib\n", + "\n", + "| Use case | Backend |\n", + "|---|---|\n", + "| Quick static plot / publication PDF | `matplotlib` (default) |\n", + "| Interactive exploration in Jupyter | `plotly` |\n", + "| Share a self-contained interactive report | `plotly` → `fig.write_html(...)` |\n", + "| LaTeX document figure | `tikzfigure` |" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext autoreload\n", + "%autoreload 2\n", + "%matplotlib inline\n", + "\n", + "from maxplotlib import Canvas\n", + "import numpy as np" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Plotly in Jupyter\n", + "\n", + "In notebooks, you can either:\n", + "\n", + "- call `canvas.show(backend=\"plotly\")` (displays and returns a Plotly figure), or\n", + "- call `fig = canvas.plot(backend=\"plotly\")` and put `fig` as the last line of a cell.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 2 * np.pi, 200)\n", + "\n", + "canvas = Canvas(width=\"10cm\", ratio=0.45)\n", + "canvas.add_line(x, np.sin(x), color=\"steelblue\", label=\"sin(x)\")\n", + "canvas.set_title(\"Displayed inline\")\n", + "canvas.set_legend(True)\n", + "\n", + "fig = canvas.show(backend=\"plotly\")\n", + "fig" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "## 1 · Basic line plot\n", + "\n", + "Switch to the Plotly backend by passing `backend='plotly'` to `canvas.plot()`. The returned object is a genuine `plotly.graph_objects.Figure`, so every Plotly method is available on it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 2 * np.pi, 200)\n", + "\n", + "canvas = Canvas(width=\"10cm\", ratio=0.55)\n", + "canvas.add_line(x, np.sin(x), color=\"steelblue\", label=\"sin(x)\")\n", + "canvas.set_xlabel(\"x\")\n", + "canvas.set_ylabel(\"y\")\n", + "canvas.set_title(\"Basic line plot\")\n", + "\n", + "fig = canvas.show(backend=\"plotly\")\n", + "fig" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "## 2 · Multiple lines\n", + "\n", + "Each `ax.plot()` call becomes a separate Plotly trace. Enable the legend with `ax.set_legend(True)` so trace labels appear." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 2 * np.pi, 200)\n", + "\n", + "canvas = Canvas(width=\"10cm\", ratio=0.55)\n", + "canvas.add_line(x, np.sin(x), color=\"steelblue\", label=\"sin(x)\", linewidth=2)\n", + "canvas.add_line(x, np.cos(x), color=\"tomato\", label=\"cos(x)\", linewidth=2)\n", + "canvas.add_line(\n", + " x,\n", + " np.sin(2 * x),\n", + " color=\"seagreen\",\n", + " label=\"sin(2x)\",\n", + " linewidth=2,\n", + " linestyle=\"dashed\",\n", + ")\n", + "\n", + "canvas.set_xlabel(\"x\")\n", + "canvas.set_ylabel(\"y\")\n", + "canvas.set_title(\"Multiple lines\")\n", + "canvas.set_legend(True)\n", + "\n", + "fig = canvas.show(backend=\"plotly\")\n", + "fig" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "## 3 · Scatter plot\n", + "\n", + "`ax.scatter()` maps to a Plotly scatter trace with markers only." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "rng = np.random.default_rng(42)\n", + "n = 120\n", + "x_data = rng.uniform(0, 10, n)\n", + "y_data = 0.5 * x_data + rng.normal(0, 1, n)\n", + "\n", + "canvas = Canvas(width=\"10cm\", ratio=0.6)\n", + "canvas.scatter(\n", + " x_data, y_data, color=\"steelblue\", marker=\"o\", s=20, label=\"observations\"\n", + ")\n", + "canvas.add_line(\n", + " [0, 10],\n", + " [0, 5],\n", + " color=\"tomato\",\n", + " linestyle=\"dashed\",\n", + " linewidth=2,\n", + " label=\"y = 0.5x\",\n", + ")\n", + "\n", + "canvas.set_xlabel(\"x\")\n", + "canvas.set_ylabel(\"y\")\n", + "canvas.set_title(\"Scatter plot with trend line\")\n", + "canvas.set_legend(True)\n", + "\n", + "fig = canvas.show(backend=\"plotly\")\n", + "fig" + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, + "source": [ + "## 4 · Bar chart\n", + "\n", + "`ax.bar()` maps to a Plotly bar trace." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [], + "source": [ + "categories = [\"Alpha\", \"Beta\", \"Gamma\", \"Delta\", \"Epsilon\"]\n", + "values = [4.2, 7.1, 3.8, 5.9, 6.4]\n", + "x_pos = np.arange(len(categories))\n", + "\n", + "canvas = Canvas(width=\"10cm\", ratio=0.6)\n", + "canvas.bar(x_pos, values, color=\"steelblue\", label=\"metric\")\n", + "canvas.set_xticks(x_pos, categories)\n", + "\n", + "canvas.set_xlabel(\"Category\")\n", + "canvas.set_ylabel(\"Value\")\n", + "canvas.set_title(\"Bar chart\")\n", + "canvas.set_legend(True)\n", + "\n", + "fig = canvas.show(backend=\"plotly\")\n", + "fig" + ] + }, + { + "cell_type": "markdown", + "id": "12", + "metadata": {}, + "source": [ + "## 5 · Mixing lines and bars\n", + "\n", + "You can place multiple trace types on the same axes — Plotly handles the overlay automatically." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13", + "metadata": {}, + "outputs": [], + "source": [ + "months = np.arange(1, 13)\n", + "rainfall = np.array([55, 48, 62, 70, 85, 40, 30, 35, 60, 90, 75, 65])\n", + "cumulative = np.cumsum(rainfall)\n", + "\n", + "canvas = Canvas(width=\"10cm\", ratio=0.6)\n", + "canvas.bar(\n", + " months, rainfall, color=\"steelblue\", alpha=0.7, label=\"monthly rainfall (mm)\"\n", + ")\n", + "canvas.add_line(\n", + " months,\n", + " cumulative / 10,\n", + " color=\"tomato\",\n", + " linewidth=2.5,\n", + " marker=\"o\",\n", + " label=\"cumulative / 10\",\n", + ")\n", + "\n", + "canvas.set_xlabel(\"Month\")\n", + "canvas.set_ylabel(\"Rainfall (mm)\")\n", + "canvas.set_title(\"Monthly rainfall + cumulative trend\")\n", + "canvas.set_legend(True)\n", + "\n", + "fig = canvas.show(backend=\"plotly\")\n", + "fig" + ] + }, + { + "cell_type": "markdown", + "id": "14", + "metadata": {}, + "source": [ + "## 6 · Multiple subplots\n", + "\n", + "Multi-subplot canvases are fully supported. Each panel gets its own axis labels and title; `canvas.suptitle(...)` sets the figure-level title." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "15", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 2 * np.pi, 200)\n", + "rng = np.random.default_rng(0)\n", + "\n", + "canvas = Canvas(ncols=1) # , width=\"14cm\", ratio=0.35)\n", + "\n", + "# Left panel — line plot\n", + "canvas.add_line(x, np.sin(x), color=\"steelblue\", label=\"sin(x)\", linewidth=2, col=0)\n", + "canvas.add_line(x, np.cos(x), color=\"tomato\", label=\"cos(x)\", linewidth=2, col=0)\n", + "canvas.set_xlabel(\"x\", col=0)\n", + "canvas.set_ylabel(\"y\", col=0)\n", + "canvas.set_title(\"Trigonometric functions\", col=0)\n", + "canvas.set_legend(True, col=0)\n", + "\n", + "# Right panel — scatter\n", + "x_s = rng.uniform(0, 6, 80)\n", + "y_s = np.sin(x_s) + rng.normal(0, 0.15, 80)\n", + "canvas.scatter(x_s, y_s, color=\"seagreen\", marker=\"o\", s=18, label=\"noisy sin\", col=1)\n", + "canvas.add_line(\n", + " x,\n", + " np.sin(x),\n", + " color=\"black\",\n", + " linestyle=\"dashed\",\n", + " linewidth=1.5,\n", + " label=\"true sin\",\n", + " col=1,\n", + ")\n", + "canvas.set_xlabel(\"x\", col=1)\n", + "canvas.set_ylabel(\"y\", col=1)\n", + "canvas.set_title(\"Noisy observations\", col=1)\n", + "canvas.set_legend(True, col=1)\n", + "\n", + "canvas.suptitle(\"Multi-panel Plotly figure\")\n", + "\n", + "fig = canvas.show(backend=\"plotly\")" + ] + }, + { + "cell_type": "markdown", + "id": "16", + "metadata": {}, + "source": [ + "## 7 · Log scale\n", + "\n", + "`ax.set_yscale('log')` is passed through to Plotly's axis type." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "17", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0.1, 5, 200)\n", + "\n", + "canvas = Canvas(width=\"10cm\", ratio=0.55)\n", + "canvas.add_line(x, np.exp(x), color=\"steelblue\", label=\"exp(x)\", linewidth=2)\n", + "canvas.add_line(x, np.exp(1.5 * x), color=\"tomato\", label=\"exp(1.5x)\", linewidth=2)\n", + "canvas.add_line(x, x**2, color=\"seagreen\", label=\"x²\", linewidth=2)\n", + "\n", + "canvas.set_xlabel(\"x\")\n", + "canvas.set_ylabel(\"y (log scale)\")\n", + "canvas.set_title(\"Log-scale y axis\")\n", + "canvas.set_yscale(\"log\")\n", + "canvas.set_legend(True)\n", + "\n", + "fig = canvas.show(backend=\"plotly\")" + ] + }, + { + "cell_type": "markdown", + "id": "18", + "metadata": {}, + "source": [ + "## 8 · Saving to HTML\n", + "\n", + "`fig.write_html()` saves a fully self-contained HTML file. The file works in any browser without Python, Plotly, or a running server — ideal for sharing interactive figures with colleagues." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "19", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 2 * np.pi, 200)\n", + "\n", + "canvas = Canvas(width=\"10cm\", ratio=0.55)\n", + "canvas.add_line(x, np.sin(x), color=\"steelblue\", label=\"sin(x)\", linewidth=2)\n", + "canvas.add_line(x, np.cos(x), color=\"tomato\", label=\"cos(x)\", linewidth=2)\n", + "canvas.set_xlabel(\"x\")\n", + "canvas.set_ylabel(\"y\")\n", + "canvas.set_title(\"Saved interactive figure\")\n", + "canvas.set_legend(True)\n", + "\n", + "# Writes a standalone HTML file — open it in any browser\n", + "canvas.savefig(\"output.html\", backend=\"plotly\")\n", + "print(\"Saved to output.html\")" + ] + }, + { + "cell_type": "markdown", + "id": "20", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "### Plotly backend feature table\n", + "\n", + "| Feature | Supported | Notes |\n", + "|---|---|---|\n", + "| `ax.plot()` — line trace | ✅ | `color`, `linestyle`, `linewidth`, `marker` all passed through |\n", + "| `ax.scatter()` — markers | ✅ | `color`, `marker`, `s`, `alpha` |\n", + "| `ax.bar()` — bar chart | ✅ | `color`, `alpha` |\n", + "| `ax.fill_between()` | ❌ | Not supported by this backend |\n", + "| `ax.errorbar()` | ❌ | Not supported by this backend |\n", + "| `ax.axhline/axvline` | ❌ | Not supported by this backend |\n", + "| Multi-subplot canvas | ✅ | `Canvas.subplots(ncols=...)` etc. |\n", + "| `canvas.suptitle()` | ✅ | Maps to figure title |\n", + "| `ax.set_yscale('log')` | ✅ | |\n", + "| `ax.set_legend(True)` | ✅ | |\n", + "| `fig.show()` | ✅ | Interactive in Jupyter |\n", + "| `fig.write_html(path)` | ✅ | Standalone interactive HTML |\n", + "\n", + "### Typical workflow\n", + "\n", + "```python\n", + "from maxplotlib import Canvas\n", + "import numpy as np\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.plot(x, y, label='data')\n", + "ax.set_legend(True)\n", + "\n", + "fig = canvas.plot(backend='plotly') # → plotly.graph_objects.Figure\n", + "fig.show() # interactive in Jupyter\n", + "fig.write_html('report.html') # share with anyone\n", + "```" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/tutorial_09_plotext.ipynb b/tutorials/tutorial_09_plotext.ipynb new file mode 100644 index 0000000..e395ded --- /dev/null +++ b/tutorials/tutorial_09_plotext.ipynb @@ -0,0 +1,738 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Tutorial 09 - plotext Backend\n", + "\n", + "The **plotext backend** renders a `maxplotlib` canvas directly in the terminal. That makes it a good fit for SSH sessions, CLI workflows, quick diagnostics, and situations where you want the same `Canvas` API without a GUI browser or desktop window.\n", + "\n", + "## What this tutorial covers\n", + "\n", + "| Area | Status |\n", + "| --- | --- |\n", + "| `plot()` / line graphs | ✅ |\n", + "| `scatter()` | ✅ |\n", + "| `bar()` | ✅ |\n", + "| `fill_between()` to a baseline | ✅ |\n", + "| `fill_between()` between two curves | ✅ |\n", + "| `errorbar()` | ✅ |\n", + "| `axhline()` / `axvline()` / `hlines()` / `vlines()` | ✅ |\n", + "| `text()` / `annotate()` | ✅ |\n", + "| Titles, labels, captions, limits, ticks, grid, log/symlog scales | ✅ |\n", + "| `set_aspect()` | ✅ |\n", + "| Layers | ✅ |\n", + "| Multi-subplot canvases | ✅ |\n", + "| `add_imshow()` matrix-style rendering | ✅ |\n", + "| `add_colorbar()` note-style summary | ✅ |\n", + "| Generic matplotlib patch geometry | ✅ (best effort) |\n", + "| Terminal animation redraw loop | ✅ |\n", + "\n", + "The goal is not pixel-identical matplotlib output. The goal is a **faithful terminal representation** of the same plot intent." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "\n", + "import matplotlib.patches as mpatches\n", + "import numpy as np\n", + "\n", + "from maxplotlib import Canvas" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## 1 · Figure lifecycle: `plot()`, `build()`, `show()`, and `savefig()`\n", + "\n", + "Use `canvas.plot(backend=\"plotext\")` when you want a reusable terminal figure object. The returned object supports:\n", + "\n", + "- `.build()` to get the rendered terminal plot as a string\n", + "- `.show()` to print it immediately\n", + "- `.savefig(path)` to write the terminal output to a text file\n", + "\n", + "Use `canvas.show(backend=\"plotext\")` when you just want direct terminal output." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 2 * np.pi, 100)\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.plot(x, np.sin(x), color=\"cyan\", label=\"sin(x)\")\n", + "ax.set_title(\"Demo\")\n", + "ax.set_xlabel(\"x\")\n", + "ax.set_ylabel(\"y\")\n", + "\n", + "terminal_fig = canvas.plot(backend=\"plotext\")\n", + "preview = terminal_fig.build(keep_colors=False)\n", + "print(preview)\n", + "\n", + "# terminal_fig.show()\n", + "# canvas.show(backend=\"plotext\")" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "## 2 · Line plots\n", + "\n", + "Line plots are the natural fit for `plotext`. Labels, markers, grid lines, axis titles, and legends all carry over cleanly." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 2 * np.pi, 120)\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.plot(x, np.sin(x), color=\"cyan\", label=\"sin(x)\")\n", + "ax.plot(x, np.cos(x), color=\"yellow\", label=\"cos(x)\", marker=\"dot\")\n", + "ax.plot(x, np.sin(2 * x), color=\"green\", label=\"sin(2x)\")\n", + "ax.set_title(\"Multiple terminal lines\")\n", + "ax.set_xlabel(\"x\")\n", + "ax.set_ylabel(\"value\")\n", + "ax.set_grid(True)\n", + "ax.set_legend(True)\n", + "\n", + "print(canvas.plot(backend=\"plotext\").build(keep_colors=False))" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "## 3 · Scatter plots\n", + "\n", + "Scatter traces use `plotext.scatter(...)` under the hood. They combine well with a line on the same axes." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "rng = np.random.default_rng(42)\n", + "x = np.linspace(0, 8, 60)\n", + "samples_x = np.linspace(0, 8, 15)\n", + "samples_y = np.sin(samples_x) + rng.normal(0, 0.15, len(samples_x))\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.plot(x, np.sin(x), color=\"white\", label=\"sin(x)\")\n", + "ax.scatter(samples_x, samples_y, color=\"red\", marker=\"x\", label=\"samples\")\n", + "ax.set_title(\"Scatter + line\")\n", + "ax.set_xlabel(\"x\")\n", + "ax.set_ylabel(\"y\")\n", + "ax.set_legend(True)\n", + "\n", + "print(canvas.plot(backend=\"plotext\").build(keep_colors=False))" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "## 4 · Bar charts\n", + "\n", + "Bars are supported too, so summary views and simple dashboards work well in the terminal." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "bins = np.arange(5)\n", + "values = np.array([4.0, 6.5, 3.2, 7.4, 5.8])\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.bar(bins, values, color=\"green\", label=\"count\")\n", + "ax.scatter(bins, values, color=\"yellow\", label=\"sample mean\")\n", + "ax.set_title(\"Bar + scatter overlay\")\n", + "ax.set_xlabel(\"bin\")\n", + "ax.set_ylabel(\"value\")\n", + "ax.set_legend(True)\n", + "\n", + "print(canvas.plot(backend=\"plotext\").build(keep_colors=False))" + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, + "source": [ + "## 5 · Filled regions\n", + "\n", + "The backend supports both common `fill_between(...)` shapes:\n", + "\n", + "1. Filling down to a scalar baseline\n", + "2. Filling the region between two full curves" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 5, 100)\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.fill_between(\n", + " x,\n", + " np.exp(-0.5 * x) * np.sin(3 * x) + 1.0,\n", + " 0.0,\n", + " color=\"cyan\",\n", + " label=\"signal envelope\",\n", + ")\n", + "ax.set_title(\"fill_between() to a scalar baseline\")\n", + "ax.set_xlabel(\"x\")\n", + "ax.set_ylabel(\"amplitude\")\n", + "ax.set_legend(True)\n", + "\n", + "print(canvas.plot(backend=\"plotext\").build(keep_colors=False))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 4, 100)\n", + "upper = np.sin(x) + 1.8\n", + "lower = np.cos(x) + 0.8\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.fill_between(x, upper, lower, color=\"blue\", label=\"between curves\")\n", + "ax.plot(x, upper, color=\"white\", label=\"upper\")\n", + "ax.plot(x, lower, color=\"yellow\", label=\"lower\")\n", + "ax.set_title(\"fill_between() between two curves\")\n", + "ax.set_legend(True)\n", + "\n", + "print(canvas.plot(backend=\"plotext\").build(keep_colors=False))" + ] + }, + { + "cell_type": "markdown", + "id": "13", + "metadata": {}, + "source": [ + "## 6 · Error bars and reference lines\n", + "\n", + "Reference-line helpers (`axhline`, `axvline`, `hlines`, `vlines`) are all available, and `errorbar()` works for scalar or array-shaped errors." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "14", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(1, 10, 9)\n", + "y = np.sqrt(x)\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.errorbar(x, y, yerr=0.15, color=\"cyan\", label=\"sqrt(x)\")\n", + "ax.axhline(2.0, color=\"white\")\n", + "ax.axvline(4.0, color=\"yellow\")\n", + "ax.hlines([1.2, 2.7], xmin=[1, 5], xmax=[3, 9], color=\"green\")\n", + "ax.vlines([2.0, 8.0], ymin=[1.0, 2.0], ymax=[1.8, 3.0], color=\"red\")\n", + "ax.set_title(\"Error bars + reference lines\")\n", + "ax.set_xlabel(\"x\")\n", + "ax.set_ylabel(\"y\")\n", + "ax.set_legend(True)\n", + "\n", + "print(canvas.plot(backend=\"plotext\").build(keep_colors=False))" + ] + }, + { + "cell_type": "markdown", + "id": "15", + "metadata": {}, + "source": [ + "## 7 · Text and annotations\n", + "\n", + "`text()` is mapped directly. `annotate()` works too, and when you pass `arrowprops`, the backend draws a connector line toward the annotated point." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "16", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 6, 120)\n", + "y = np.sin(x)\n", + "peak_x = x[np.argmax(y)]\n", + "peak_y = y.max()\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.plot(x, y, color=\"cyan\")\n", + "ax.text(0.8, -0.8, \"terminal note\", color=\"yellow\")\n", + "ax.annotate(\n", + " \"peak\",\n", + " xy=(peak_x, peak_y),\n", + " xytext=(4.4, 0.4),\n", + " color=\"white\",\n", + " arrowprops={\"color\": \"green\"},\n", + ")\n", + "ax.set_title(\"Text and annotations\")\n", + "\n", + "print(canvas.plot(backend=\"plotext\").build(keep_colors=False))" + ] + }, + { + "cell_type": "markdown", + "id": "17", + "metadata": {}, + "source": [ + "## 8 · Limits, ticks, grid, and log scales\n", + "\n", + "Axis metadata is one of the nice parts of keeping the same `LinePlot` API across backends." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "18", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(1, 20, 120)\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.plot(x, x**0.5, color=\"cyan\", label=\"sqrt(x)\")\n", + "ax.plot(x, np.log(x + 1), color=\"yellow\", label=\"log(x + 1)\")\n", + "ax.set_title(\"Axis controls\")\n", + "ax.set_xlabel(\"x\")\n", + "ax.set_ylabel(\"value\")\n", + "ax.set_xlim(1, 20)\n", + "ax.set_ylim(0, 5)\n", + "ax.set_xticks([1, 2, 5, 10, 20], [\"1\", \"2\", \"5\", \"10\", \"20\"])\n", + "ax.set_yticks([0, 1, 2, 3, 4, 5])\n", + "ax.set_xscale(\"log\")\n", + "ax.set_grid(True)\n", + "ax.set_legend(True)\n", + "\n", + "print(canvas.plot(backend=\"plotext\").build(keep_colors=False))" + ] + }, + { + "cell_type": "markdown", + "id": "19", + "metadata": {}, + "source": [ + "## 9 · Layers\n", + "\n", + "Layer filtering works with the terminal backend too. This is useful when you want progressive reveals or to inspect subsets of a figure." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "20", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 2 * np.pi, 100)\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.plot(x, np.sin(x), color=\"cyan\", label=\"layer 0\", layer=0)\n", + "ax.plot(x, np.cos(x), color=\"yellow\", label=\"layer 1\", layer=1)\n", + "ax.fill_between(x, np.sin(x) + 1.5, 0.0, color=\"green\", label=\"layer 2\", layer=2)\n", + "ax.set_title(\"Layers 0 and 1 only\")\n", + "ax.set_legend(True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "21", + "metadata": {}, + "outputs": [], + "source": [ + "print(canvas.plot(backend=\"plotext\", layers=[0]).build(keep_colors=False))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "22", + "metadata": {}, + "outputs": [], + "source": [ + "print(canvas.plot(backend=\"plotext\", layers=[1]).build(keep_colors=False))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "23", + "metadata": {}, + "outputs": [], + "source": [ + "print(canvas.plot(backend=\"plotext\", layers=[0, 1]).build(keep_colors=False))" + ] + }, + { + "cell_type": "markdown", + "id": "24", + "metadata": {}, + "source": [ + "## 10 · Multi-subplot canvases\n", + "\n", + "Subplots are fully supported, including figure-level titles via `canvas.suptitle(...)`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "25", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 2 * np.pi, 80)\n", + "rng = np.random.default_rng(5)\n", + "\n", + "canvas, (ax1, ax2) = Canvas.subplots(ncols=2)\n", + "\n", + "ax1.plot(x, np.sin(x), color=\"cyan\", label=\"sin(x)\")\n", + "ax1.plot(x, np.cos(x), color=\"yellow\", label=\"cos(x)\")\n", + "ax1.set_title(\"Signals\")\n", + "ax1.set_xlabel(\"x\")\n", + "ax1.set_ylabel(\"value\")\n", + "ax1.set_legend(True)\n", + "\n", + "cats = np.arange(6)\n", + "vals = rng.integers(2, 9, size=6)\n", + "ax2.bar(cats, vals, color=\"green\", label=\"count\")\n", + "ax2.scatter(cats, vals, color=\"red\", label=\"points\")\n", + "ax2.set_title(\"Counts\")\n", + "ax2.set_xlabel(\"bin\")\n", + "ax2.set_ylabel(\"value\")\n", + "ax2.set_legend(True)\n", + "\n", + "canvas.suptitle(\"Terminal dashboard\")\n", + "print(canvas.plot(backend=\"plotext\").build(keep_colors=False))" + ] + }, + { + "cell_type": "markdown", + "id": "26", + "metadata": {}, + "source": [ + "## 11 · Matrix-style `imshow()` output\n", + "\n", + "`add_imshow()` is rendered as a terminal matrix plot. This is the terminal approximation of image-like numeric data, not a full matplotlib colormap implementation." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "27", + "metadata": {}, + "outputs": [], + "source": [ + "data = np.arange(1, 26).reshape(5, 5)\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.add_imshow(data)\n", + "ax.set_title(\"Matrix-style imshow\")\n", + "ax.set_xlabel(\"column\")\n", + "ax.set_ylabel(\"row\")\n", + "\n", + "print(canvas.plot(backend=\"plotext\").build(keep_colors=False))" + ] + }, + { + "cell_type": "markdown", + "id": "28", + "metadata": {}, + "source": [ + "## 12 · Patches and arbitrary patch geometry\n", + "\n", + "The backend supports common matplotlib patch types directly and also uses matplotlib patch geometry as a best-effort fallback for many other patch subclasses.\n", + "\n", + "- `matplotlib.patches.Rectangle`\n", + "- `matplotlib.patches.Circle`\n", + "- `matplotlib.patches.Polygon`\n", + "- `matplotlib.patches.Ellipse`\n", + "- many other patch types that expose a usable matplotlib path\n", + "\n", + "That is enough for many annotations, regions of interest, and geometric callouts." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "29", + "metadata": {}, + "outputs": [], + "source": [ + "canvas, ax = Canvas.subplots()\n", + "ax.add_patch(\n", + " mpatches.Rectangle(\n", + " (0.2, 0.2), 1.3, 0.7, fill=False, edgecolor=\"yellow\", label=\"window\"\n", + " )\n", + ")\n", + "ax.add_patch(\n", + " mpatches.Circle((2.2, 1.6), 0.45, fill=False, edgecolor=\"cyan\", label=\"sensor\")\n", + ")\n", + "ax.add_patch(\n", + " mpatches.Polygon(\n", + " [[3.0, 0.5], [3.8, 1.2], [3.4, 2.0]],\n", + " fill=True,\n", + " facecolor=\"green\",\n", + " label=\"region\",\n", + " )\n", + ")\n", + "ax.add_patch(\n", + " mpatches.Ellipse(\n", + " (2.8, 1.0), 0.8, 0.5, fill=False, edgecolor=\"white\", label=\"ellipse\"\n", + " )\n", + ")\n", + "ax.set_xlim(0, 4.5)\n", + "ax.set_ylim(0, 2.5)\n", + "ax.set_title(\"Supported patch types\")\n", + "ax.set_legend(True)\n", + "\n", + "print(canvas.plot(backend=\"plotext\").build(keep_colors=False))" + ] + }, + { + "cell_type": "markdown", + "id": "30", + "metadata": {}, + "source": [ + "### Plotly backend note (patches)\n", + "\n", + "Plotly can render common Matplotlib patches too.\n", + "Note: Plotly “shapes” do not appear in legends, so maxplotlib adds a small dummy legend entry for labeled patches.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "31", + "metadata": {}, + "outputs": [], + "source": [ + "# Interactive rendering of the same canvas\n", + "canvas.show(backend=\"plotly\")" + ] + }, + { + "cell_type": "markdown", + "id": "32", + "metadata": {}, + "source": [ + "## 13 · Captions, symlog scales, aspect, and colorbar notes\n", + "\n", + "The last backend-specific pieces of the `LinePlot` surface also work in the terminal backend:\n", + "\n", + "- `add_caption(...)`\n", + "- `set_xscale('symlog')` and `set_yscale('symlog')`\n", + "- `set_aspect(...)` with a best-effort terminal plotsize approximation\n", + "- `add_colorbar(...)` as a note-style value summary for matrix/image output" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "33", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(-20, 20, 161)\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.plot(x, x**3, color=\"cyan\", label=\"x^3\")\n", + "ax.set_title(\"Symlog example\")\n", + "ax.add_caption(\"caption text\")\n", + "ax.set_xscale(\"symlog\")\n", + "ax.set_yscale(\"symlog\")\n", + "ax.set_aspect(\"equal\")\n", + "ax.set_legend(True)\n", + "\n", + "print(canvas.plot(backend=\"plotext\").build(keep_colors=False))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "34", + "metadata": {}, + "outputs": [], + "source": [ + "heat = np.arange(1, 26).reshape(5, 5)\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.add_imshow(heat)\n", + "ax.add_colorbar(label=\"intensity\")\n", + "ax.set_title(\"Matrix + colorbar note\")\n", + "\n", + "print(canvas.plot(backend=\"plotext\").build(keep_colors=False))" + ] + }, + { + "cell_type": "markdown", + "id": "35", + "metadata": {}, + "source": [ + "## 14 · Saving terminal output to a file\n", + "\n", + "Saving with the plotext backend writes the rendered terminal figure to a text file. By default the saved text is plain and easy to inspect in editors, CI logs, or generated artifacts." + ] + }, + { + "cell_type": "markdown", + "id": "36", + "metadata": {}, + "source": [ + "### Plotly backend note (symlog)\n", + "\n", + "Plotly has no native symlog axis type. For `symlog`, maxplotlib applies a symmetric log transform to the data and uses a linear Plotly axis.\n", + "This keeps the plot working across backends, but tick formatting may differ from Matplotlib/plotext.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "37", + "metadata": {}, + "outputs": [], + "source": [ + "canvas.show(backend=\"plotly\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "38", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 2 * np.pi, 60)\n", + "\n", + "canvas, ax = Canvas.subplots()\n", + "ax.plot(x, np.sin(x), color=\"cyan\", label=\"sin(x)\")\n", + "ax.set_title(\"Saved terminal figure\")\n", + "terminal_fig = canvas.plot(backend=\"plotext\")\n", + "\n", + "output_path = Path(\"plotext_output.txt\")\n", + "terminal_fig.savefig(output_path)\n", + "print(output_path.read_text().splitlines()[0])" + ] + }, + { + "cell_type": "markdown", + "id": "39", + "metadata": {}, + "source": [ + "## 15 · A simple terminal animation\n", + "\n", + "`plotext` does not provide browser-widget animation like Plotly or Matplotlib's GUI animation stack, but terminal animation is still practical: render a frame, clear the output, sleep briefly, and repeat.\n", + "\n", + "The example below works well in a notebook cell or a terminal Python session. In notebooks, `clear_output(wait=True)` keeps the cell output updating in place." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "40", + "metadata": {}, + "outputs": [], + "source": [ + "import time\n", + "\n", + "from IPython.display import clear_output\n", + "\n", + "x = np.linspace(0, 2 * np.pi, 120)\n", + "\n", + "for phase in np.linspace(0, 2 * np.pi, 24):\n", + " canvas, ax = Canvas.subplots()\n", + " ax.plot(x, np.sin(x + phase), color=\"cyan\", label=\"sin(x + phase)\")\n", + " ax.plot(x, np.cos(x + phase), color=\"yellow\", label=\"cos(x + phase)\")\n", + " ax.set_ylim(-1.2, 1.2)\n", + " ax.set_title(f\"Animated phase = {phase:.2f}\")\n", + " ax.set_legend(True)\n", + "\n", + " clear_output(wait=True)\n", + " print(canvas.plot(backend=\"plotext\").build(keep_colors=False))\n", + " time.sleep(0.08)" + ] + }, + { + "cell_type": "markdown", + "id": "41", + "metadata": {}, + "source": [ + "## 16 · Current limitations\n", + "\n", + "| Feature | Status | Notes |\n", + "| --- | --- | --- |\n", + "| `add_colorbar()` | ⚠️ | rendered as a compact note-style min/max summary rather than a true continuous side bar |\n", + "| Arbitrary matplotlib patch subclasses | ⚠️ | best-effort path extraction works for many patch types, but not every custom patch will map perfectly |\n", + "| Exact matplotlib styling parity | ❌ | Terminal rendering is approximate by design |\n", + "| Notebook-style high-frame-rate animation | ⚠️ | possible via redraw loops, but not a substitute for GUI animation widgets |\n", + "\n", + "For terminal work, this backend now covers a large and practical portion of the `LinePlot` API. When you need richer styling, publication export, or interactive browser behavior, use the matplotlib, tikzfigure, or plotly backends instead." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/tutorial_10_matplotlib_nxm_spacing.ipynb b/tutorials/tutorial_10_matplotlib_nxm_spacing.ipynb new file mode 100644 index 0000000..b5bebab --- /dev/null +++ b/tutorials/tutorial_10_matplotlib_nxm_spacing.ipynb @@ -0,0 +1,210 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Tutorial 10 - Matplotlib NxM Subplots and Spacing\n", + "\n", + "This tutorial shows how to build **NxM subplot grids** with the matplotlib backend and control the distance between plots using `wspace=...` and `hspace=...`.\n", + "\n", + "It includes both:\n", + "- line plots\n", + "- color plots (`add_imshow`)\n", + "\n", + "and prints measured subplot gaps so you can verify spacing changes numerically." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "\n", + "from maxplotlib import Canvas" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "def measure_gaps(axes):\n", + " \"\"\"Measure one representative horizontal and vertical subplot gap.\"\"\"\n", + " horizontal_gap = axes[0, 1].get_position().x0 - axes[0, 0].get_position().x1\n", + " vertical_gap = axes[0, 0].get_position().y0 - axes[1, 0].get_position().y1\n", + " return horizontal_gap, vertical_gap" + ] + }, + { + "cell_type": "markdown", + "id": "3", + "metadata": {}, + "source": [ + "## 1 · NxM line plots with spacing control" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.linspace(0, 2 * np.pi, 200)\n", + "\n", + "tight_canvas, tight_axes = Canvas.subplots(\n", + " nrows=2,\n", + " ncols=3,\n", + " width=\"14cm\",\n", + " ratio=0.65,\n", + " wspace=0.05,\n", + " hspace=0.08,\n", + ")\n", + "for i, row in enumerate(tight_axes):\n", + " for j, ax in enumerate(row):\n", + " ax.plot(x, np.sin((i + 1) * (j + 1) * x), label=f\"sin({(i + 1) * (j + 1)}x)\")\n", + " ax.set_title(f\"line {i},{j}\")\n", + "\n", + "tight_fig, tight_m_axes = tight_canvas.plot(backend=\"matplotlib\")\n", + "tight_fig.suptitle(\"Line plots - tight spacing\")\n", + "tight_h, tight_v = measure_gaps(tight_m_axes)\n", + "print(f\"tight line gaps: h={tight_h:.4f}, v={tight_v:.4f}\")\n", + "\n", + "loose_canvas, loose_axes = Canvas.subplots(\n", + " nrows=2,\n", + " ncols=3,\n", + " width=\"14cm\",\n", + " ratio=0.65,\n", + " wspace=0.45,\n", + " hspace=0.45,\n", + ")\n", + "for i, row in enumerate(loose_axes):\n", + " for j, ax in enumerate(row):\n", + " ax.plot(x, np.sin((i + 1) * (j + 1) * x), label=f\"sin({(i + 1) * (j + 1)}x)\")\n", + " ax.set_title(f\"line {i},{j}\")\n", + "\n", + "loose_fig, loose_m_axes = loose_canvas.plot(backend=\"matplotlib\")\n", + "loose_fig.suptitle(\"Line plots - loose spacing\")\n", + "loose_h, loose_v = measure_gaps(loose_m_axes)\n", + "print(f\"loose line gaps: h={loose_h:.4f}, v={loose_v:.4f}\")\n", + "\n", + "assert loose_h > tight_h\n", + "assert loose_v > tight_v" + ] + }, + { + "cell_type": "markdown", + "id": "5", + "metadata": {}, + "source": [ + "## 2 · NxM color plots (`imshow`) with spacing control" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], + "source": [ + "base = np.arange(100).reshape(10, 10)\n", + "\n", + "tight_canvas, tight_axes = Canvas.subplots(\n", + " nrows=2,\n", + " ncols=3,\n", + " width=\"14cm\",\n", + " ratio=0.75,\n", + " wspace=0.05,\n", + " hspace=0.08,\n", + ")\n", + "idx = 0\n", + "for row in tight_axes:\n", + " for ax in row:\n", + " ax.add_imshow(base + idx, cmap=\"viridis\")\n", + " ax.set_title(f\"heatmap {idx}\")\n", + " idx += 1\n", + "\n", + "tight_fig, tight_m_axes = tight_canvas.plot(backend=\"matplotlib\")\n", + "tight_fig.suptitle(\"Color plots - tight spacing\")\n", + "tight_h, tight_v = measure_gaps(tight_m_axes)\n", + "print(f\"tight color gaps: h={tight_h:.4f}, v={tight_v:.4f}\")\n", + "\n", + "loose_canvas, loose_axes = Canvas.subplots(\n", + " nrows=2,\n", + " ncols=3,\n", + " width=\"14cm\",\n", + " ratio=0.75,\n", + " wspace=0.45,\n", + " hspace=0.45,\n", + ")\n", + "idx = 0\n", + "for row in loose_axes:\n", + " for ax in row:\n", + " ax.add_imshow(base + idx, cmap=\"viridis\")\n", + " ax.set_title(f\"heatmap {idx}\")\n", + " idx += 1\n", + "\n", + "loose_fig, loose_m_axes = loose_canvas.plot(backend=\"matplotlib\")\n", + "loose_fig.suptitle(\"Color plots - loose spacing\")\n", + "loose_h, loose_v = measure_gaps(loose_m_axes)\n", + "print(f\"loose color gaps: h={loose_h:.4f}, v={loose_v:.4f}\")\n", + "\n", + "assert loose_h > tight_h\n", + "assert loose_v > tight_v\n", + "\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": {}, + "source": [ + "## Plotly preview\n", + "\n", + "The spacing measurements above are Matplotlib-specific. You can still preview the same canvas with the Plotly backend (layout/spacing will differ):\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8", + "metadata": {}, + "outputs": [], + "source": [ + "# Reuse the last canvas created above\n", + "loose_canvas.show(backend=\"plotly\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/tutorial_tikzfigure_subplots.ipynb b/tutorials/tutorial_tikzfigure_subplots.ipynb new file mode 100644 index 0000000..faedb7a --- /dev/null +++ b/tutorials/tutorial_tikzfigure_subplots.ipynb @@ -0,0 +1,164 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# TikzFigure Subplots Tutorial\n", + "\n", + "This tutorial demonstrates how to create side-by-side subplots using the `tikzfigure` backend.\n", + "\n", + "## Basic 1×2 Layout\n", + "\n", + "The simplest use case: two plots side by side." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "from maxplotlib import Canvas\n", + "import numpy as np\n", + "\n", + "%load_ext autoreload\n", + "%autoreload 2" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "# Create a 1×2 canvas with custom width\n", + "x = np.linspace(0, 2 * np.pi, 200)\n", + "canvas, (ax1, ax2) = Canvas.subplots(ncols=2, width=\"10cm\", ratio=0.3)\n", + "\n", + "# Left subplot: sine wave\n", + "ax1.plot(x, np.sin(x), color=\"royalblue\", label=\"sin(x)\", linewidth=1.5)\n", + "ax1.set_title(\"sin(x)\")\n", + "ax1.set_xlabel(\"x\")\n", + "ax1.set_ylabel(\"amplitude\")\n", + "\n", + "# Right subplot: cosine wave\n", + "ax2.plot(x, np.cos(x), color=\"tomato\", label=\"cos(x)\", linewidth=1.5)\n", + "ax2.set_title(\"cos(x)\")\n", + "ax2.set_xlabel(\"x\")\n", + "\n", + "# Set overall title\n", + "canvas.suptitle(\"1 × 2 Layout: Trigonometric Functions\")\n", + "\n", + "# Render with tikzfigure backend\n", + "result = canvas.show(backend=\"tikzfigure\")" + ] + }, + { + "cell_type": "markdown", + "id": "3", + "metadata": {}, + "source": [ + "## Inspecting the generated subplot dimensions\n", + "\n", + "The exported TikZ splits the available width across the columns and keeps the requested overall height.\n", + "Printing the `\\\\nextgroupplot[...]` lines makes that explicit." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "tikz = canvas.plot(backend=\"tikzfigure\")\n", + "subplot_code = tikz.generate_tikz()\n", + "\n", + "for line in subplot_code.splitlines():\n", + " if \"nextgroupplot\" in line:\n", + " print(line.strip())\n", + "\n", + "# With width=\"10cm\" and ratio=0.3, each subplot gets its own width entry." + ] + }, + { + "cell_type": "markdown", + "id": "5", + "metadata": {}, + "source": [ + "## 1×3 Layout: Multiple Functions\n", + "\n", + "You can create layouts with more than 2 subplots." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], + "source": [ + "# Create a 1×3 canvas\n", + "canvas, axes = Canvas.subplots(ncols=3, width=\"12cm\", ratio=0.35)\n", + "\n", + "# Three different functions\n", + "x = np.linspace(0, 2 * np.pi, 100)\n", + "\n", + "axes[0].plot(x, np.sin(x), color=\"blue\")\n", + "axes[0].set_title(\"sin(x)\")\n", + "axes[0].set_xlabel(\"x\")\n", + "\n", + "axes[1].plot(x, np.cos(x), color=\"red\")\n", + "axes[1].set_title(\"cos(x)\")\n", + "axes[1].set_xlabel(\"x\")\n", + "\n", + "axes[2].plot(x, np.tan(x), color=\"darkgreen\")\n", + "axes[2].set_title(\"tan(x)\")\n", + "axes[2].set_xlabel(\"x\")\n", + "axes[2].set_ylim(-5, 5) # Limit y-range for tan\n", + "\n", + "canvas.suptitle(\"1 × 3 Layout: Three Trigonometric Functions\")\n", + "result = canvas.show(backend=\"tikzfigure\")\n", + "print(result)" + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": {}, + "source": [ + "## Important Notes\n", + "\n", + "- Only **horizontal layouts (1×n)** are supported with tikzfigure backend\n", + "- Vertical/grid layouts (nrows > 1) will raise an error\n", + "- Use matplotlib backend for complex layouts or grids\n", + "- Each subplot's title becomes a pgfplots `title=` entry in the generated LaTeX output" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +}