From b1580f45317f54c7a1689eee5e0d2121055d421c Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 27 Oct 2026 10:27:34 +0100 Subject: [PATCH 1/2] Use ruff for formatting and import sorting, with the shared pre-commit config --- .gitattributes | 2 +- .github/workflows/ci_pipeline.yml | 2 +- .github/workflows/publish.yml | 1 - .github/workflows/static_analysis.yml | 47 +- .pre-commit-config.yaml | 30 +- README.md | 41 +- README.qmd | 29 +- .../docs/getting-started/quickstart.md | 2 +- pyproject.toml | 11 +- pytest.ini | 2 +- .../backends/matplotlib/import_state.py | 114 +- .../backends/matplotlib/importer.py | 845 ++++++++------- src/maxplotlib/backends/plotext/figure.py | 24 +- src/maxplotlib/backends/tikzfigure/convert.py | 987 +++++++++--------- src/maxplotlib/canvas/canvas.py | 202 ++-- src/maxplotlib/subfigure/line_plot.py | 390 +++---- src/maxplotlib/tests/test_gantt_chart.py | 2 +- src/maxplotlib/utils/xarray_support.py | 42 +- src/maxplotlib/xarray.py | 106 +- tutorials/tutorial_01.ipynb | 3 +- tutorials/tutorial_02.ipynb | 5 +- tutorials/tutorial_03.ipynb | 3 +- tutorials/tutorial_04.ipynb | 5 +- tutorials/tutorial_05.ipynb | 5 +- tutorials/tutorial_06.ipynb | 5 +- tutorials/tutorial_07_tikz.ipynb | 4 +- tutorials/tutorial_08_plotly.ipynb | 5 +- .../tutorial_10_matplotlib_nxm_spacing.ipynb | 2 +- .../tutorial_13_advanced_matplotlib.ipynb | 1 + ...tutorial_14_axis_and_layout_controls.ipynb | 1 + .../tutorial_15_tikzfigure_subplots.ipynb | 3 +- tutorials/tutorial_17_xarray.ipynb | 2 +- 32 files changed, 1464 insertions(+), 1459 deletions(-) diff --git a/.gitattributes b/.gitattributes index 2f41707..8425c58 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1 @@ -*.ipynb filter=strip-notebook-output +*.ipynb filter=strip-notebook-output diff --git a/.github/workflows/ci_pipeline.yml b/.github/workflows/ci_pipeline.yml index d3a6406..219ef20 100644 --- a/.github/workflows/ci_pipeline.yml +++ b/.github/workflows/ci_pipeline.yml @@ -44,7 +44,7 @@ jobs: run: | coverage run -m pytest . coverage report --sort=cover - + - name: Test tutorials run: | jupyter nbconvert --to notebook --execute tutorials/*.ipynb \ diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index eaff1cd..2e65b3f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -31,4 +31,3 @@ jobs: TWINE_USERNAME: __token__ # Use API token TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} run: twine upload dist/* - diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index e3fa1e5..d2ea9fa 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -24,40 +24,6 @@ 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[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/ - mypy: runs-on: ubuntu-latest steps: @@ -103,11 +69,18 @@ jobs: with: python-version: "3.11" - - name: Lint with ruff - continue-on-error: true + - name: Linting with ruff (including import sorting) run: | pip install ruff - ruff check src/ + ruff check . + + - name: Code formatting with ruff + run: ruff format --check . + + - name: Check statement order + run: | + pip install ssort + ssort --check --diff . pylint: runs-on: ubuntu-latest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index af67085..2effd49 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,15 +1,27 @@ +--- repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 # Use the latest stable version + rev: v6.0.0 hooks: + - id: check-yaml + - id: check-toml + - id: end-of-file-fixer + - id: trailing-whitespace - id: check-added-large-files # Prevent giant files from being committed. - args: ["--maxkb=1000"] - - id: check-merge-conflict # Check for files that contain merge conflict strings. - - id: check-toml # Attempts to load all TOML files to verify syntax. - - id: check-yaml # Attempts to load all yaml files to verify syntax. - args: ["--unsafe"] - + args: [--maxkb=500] + exclude: ^(uv\.lock|docs/package-lock\.json)$ # generated lock files + - id: check-merge-conflict # Check for files that contain merge conflict strings. - repo: https://github.com/kynan/nbstripout - rev: 0.8.1 + rev: 0.9.1 hooks: - - id: nbstripout # remove jupyter notebook cell output + - id: nbstripout # strip jupyter notebook output + - repo: https://github.com/bwhmather/ssort + rev: 0.17.0 + hooks: + - id: ssort # order statements so names are defined before they are used + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.17.3 + hooks: + - id: ruff-check # lint, including import sorting (the I rules) + args: [--fix] + - id: ruff-format diff --git a/README.md b/README.md index 11f4beb..8961f24 100644 --- a/README.md +++ b/README.md @@ -78,18 +78,20 @@ providing access to any Matplotlib API without requiring a maxplotlib wrapper: ``` python -canvas.plot(matplotlib_customizations={ - "figure": { - "suptitle": "My figure", - }, - "axes": { - "tick_params": { - "axis": "both", - "which": "major", - "length": 6, +canvas.plot( + matplotlib_customizations={ + "figure": { + "suptitle": "My figure", }, - }, -}) + "axes": { + "tick_params": { + "axis": "both", + "which": "major", + "length": 6, + }, + }, + } +) ``` For dynamic customization, the same option also accepts a function: @@ -100,6 +102,7 @@ def customize(fig, axes): for ax in axes.flat: ax.tick_params(axis="both", which="major", length=6) + canvas.plot(matplotlib_customizations=customize) ``` @@ -188,7 +191,7 @@ print(terminal_fig.build(keep_colors=False)) ``` Trigonometry - Runtime + Runtime ┌┬─────────────────┬─────────────────┬─────────────────┬─────────────────┬┐ 1.00┼ ▞▞ sin(x) ──▗▄▞▀▀▀▀▀▙▄▖────────────┼─────────────────┼─────────────▄▄▀▀▀┤ │ ▞▞ cos(x) ▄▀▘ │ ▝▀▄ │ │ ▄▞▀ ││ @@ -208,8 +211,8 @@ print(terminal_fig.build(keep_colors=False)) ││ │ ▀▄▖ │ ▗▄▀ ▀▄▖ │ ▗▄▀▘ ││ -1.00┼┼─────────────────┼────────────▝▀▚▄▄▄▄▄▞▀▘───────▝▀▜▄▄▄▄▄▞▀▘────────────┼┤ └┼─────────────────┼─────────────────┼─────────────────┼─────────────────┼┘ - 0.0 1.6 3.1 4.7 6.3 - Duration Time + 0.0 1.6 3.1 4.7 6.3 + Duration Time Or plot with the TikZ backend: @@ -283,7 +286,7 @@ ax.set_legend(True) canvas.show(backend="plotext") ``` - Terminal plot + Terminal plot ┌──────────────────────────────────────────────────────────────────────────┐ 3.16┤ ▞▞ sqrt(x) ▄▞│ │ │▗▄▞▀ │ @@ -303,8 +306,8 @@ canvas.show(backend="plotext") │┼ ▗▄▄▞▀▀▀▀▀ │ 0.93┤│▀▀▘ │ └┬─────────────────┬──────────────────┬─────────────────┬─────────────────┬┘ - 1.0 1.8 3.2 5.6 10.0 - y x + 1.0 1.8 3.2 5.6 10.0 + y x @@ -383,7 +386,9 @@ wave = xr.DataArray( attrs={"long_name": "Potential", "units": "V"}, ) -wave.maxplot.pcolormesh(col="t", col_wrap=3, canvas_kwargs={"width": "16cm", "ratio": 0.6}).show() +wave.maxplot.pcolormesh( + col="t", col_wrap=3, canvas_kwargs={"width": "16cm", "ratio": 0.6} +).show() ``` ![](README_files/figure-commonmark/cell-20-output-1.png) diff --git a/README.qmd b/README.qmd index 13111d0..527e7d8 100644 --- a/README.qmd +++ b/README.qmd @@ -77,18 +77,20 @@ any Matplotlib API without requiring a maxplotlib wrapper: ```{python} #| output: false -canvas.plot(matplotlib_customizations={ - "figure": { - "suptitle": "My figure", - }, - "axes": { - "tick_params": { - "axis": "both", - "which": "major", - "length": 6, +canvas.plot( + matplotlib_customizations={ + "figure": { + "suptitle": "My figure", }, - }, -}) + "axes": { + "tick_params": { + "axis": "both", + "which": "major", + "length": 6, + }, + }, + } +) ``` For dynamic customization, the same option also accepts a function: @@ -100,6 +102,7 @@ def customize(fig, axes): for ax in axes.flat: ax.tick_params(axis="both", which="major", length=6) + canvas.plot(matplotlib_customizations=customize) ``` @@ -310,7 +313,9 @@ wave = xr.DataArray( attrs={"long_name": "Potential", "units": "V"}, ) -wave.maxplot.pcolormesh(col="t", col_wrap=3, canvas_kwargs={"width": "16cm", "ratio": 0.6}).show() +wave.maxplot.pcolormesh( + col="t", col_wrap=3, canvas_kwargs={"width": "16cm", "ratio": 0.6} +).show() ``` The same works through Canvas methods, e.g. `canvas.plot(da, hue="species")`, diff --git a/docs/src/content/docs/getting-started/quickstart.md b/docs/src/content/docs/getting-started/quickstart.md index 2e85798..3053a1e 100644 --- a/docs/src/content/docs/getting-started/quickstart.md +++ b/docs/src/content/docs/getting-started/quickstart.md @@ -44,7 +44,7 @@ canvas.plot_many( The same canvas renders with every backend: ```python -canvas.show(backend="plotly") # an interactive Plotly figure +canvas.show(backend="plotly") # an interactive Plotly figure canvas.show(backend="plotext") # a plot in the terminal canvas.savefig("sine.pdf", backend="tikzfigure") # TikZ code, compiled with LaTeX ``` diff --git a/pyproject.toml b/pyproject.toml index 184d445..59366fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,8 +40,6 @@ docs = [ dev = [ "maxplotlibx[test,docs]", "ruff", - "black[jupyter]", - "isort", "jupyterlab", "nbstripout", "pre-commit", @@ -53,11 +51,8 @@ dev = [ [tool.setuptools.packages.find] where = ["src"] -[tool.black] -line-length = 88 - [tool.ruff] line-length = 88 - -[tool.isort] -profile = "black" +# formatting: `ruff format`; import sorting: the isort rules of `ruff check` +# an explicit rule set: ruff's default one grows between releases +lint.select = ["E4", "E7", "E9", "F", "I"] diff --git a/pytest.ini b/pytest.ini index b4fff9a..3730074 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,4 +1,4 @@ # pytest.ini [pytest] addopts = -ra -q -pythonpath = src \ No newline at end of file +pythonpath = src diff --git a/src/maxplotlib/backends/matplotlib/import_state.py b/src/maxplotlib/backends/matplotlib/import_state.py index 286dedc..fb864a8 100644 --- a/src/maxplotlib/backends/matplotlib/import_state.py +++ b/src/maxplotlib/backends/matplotlib/import_state.py @@ -175,6 +175,63 @@ def draw(self, ax, **overrides): return artist +def _text_properties(text): + return dict( + fontproperties=copy.deepcopy(text.get_fontproperties()), + color=text.get_color(), + rotation=text.get_rotation(), + rotation_mode=text.get_rotation_mode(), + horizontalalignment=text.get_ha(), + verticalalignment=text.get_va(), + visible=text.get_visible(), + usetex=text.get_usetex(), + ) + + +def _tick_params(axis, which): + tick = getattr(axis, "get_" + which + "_ticks")(1)[0] + defaults = dict( + length=tick._size, + width=tick._width, + pad=tick._base_pad, + direction=tick._tickdir, + color=tick.tick1line.get_color(), + ) + defaults.update(copy.deepcopy(getattr(axis, "_" + which + "_tick_kw"))) + return defaults + + +def _tick_line_style(line): + return { + name: getattr(line, "get_" + name)() + for name in ( + "color", + "marker", + "markersize", + "markeredgewidth", + "markeredgecolor", + "visible", + "zorder", + ) + } + + +def _tick_style(tick): + return dict( + tick1line=_tick_line_style(tick.tick1line), + tick2line=_tick_line_style(tick.tick2line), + label1=_text_properties(tick.label1), + label2=_text_properties(tick.label2), + gridline=dict( + visible=tick.gridline.get_visible(), + color=tick.gridline.get_color(), + linewidth=tick.gridline.get_linewidth(), + linestyle=tick.gridline.get_linestyle(), + alpha=tick.gridline.get_alpha(), + ), + ) + + def capture_axis_state(ax): """Capture decorations which have no backend-neutral equivalent yet.""" state = {"axes": {}, "spines": {}, "titles": {}} @@ -236,63 +293,6 @@ def capture_axis_state(ax): return state -def _text_properties(text): - return dict( - fontproperties=copy.deepcopy(text.get_fontproperties()), - color=text.get_color(), - rotation=text.get_rotation(), - rotation_mode=text.get_rotation_mode(), - horizontalalignment=text.get_ha(), - verticalalignment=text.get_va(), - visible=text.get_visible(), - usetex=text.get_usetex(), - ) - - -def _tick_params(axis, which): - tick = getattr(axis, "get_" + which + "_ticks")(1)[0] - defaults = dict( - length=tick._size, - width=tick._width, - pad=tick._base_pad, - direction=tick._tickdir, - color=tick.tick1line.get_color(), - ) - defaults.update(copy.deepcopy(getattr(axis, "_" + which + "_tick_kw"))) - return defaults - - -def _tick_line_style(line): - return { - name: getattr(line, "get_" + name)() - for name in ( - "color", - "marker", - "markersize", - "markeredgewidth", - "markeredgecolor", - "visible", - "zorder", - ) - } - - -def _tick_style(tick): - return dict( - tick1line=_tick_line_style(tick.tick1line), - tick2line=_tick_line_style(tick.tick2line), - label1=_text_properties(tick.label1), - label2=_text_properties(tick.label2), - gridline=dict( - visible=tick.gridline.get_visible(), - color=tick.gridline.get_color(), - linewidth=tick.gridline.get_linewidth(), - linestyle=tick.gridline.get_linestyle(), - alpha=tick.gridline.get_alpha(), - ), - ) - - def apply_axis_state(ax, state, *, units=True): for name, props in state["spines"].items(): props = copy.deepcopy(props) diff --git a/src/maxplotlib/backends/matplotlib/importer.py b/src/maxplotlib/backends/matplotlib/importer.py index 15036b2..80d5db6 100644 --- a/src/maxplotlib/backends/matplotlib/importer.py +++ b/src/maxplotlib/backends/matplotlib/importer.py @@ -34,275 +34,6 @@ ) -def import_matplotlib( - canvas_cls, - source, - *, - strict=False, - trusted=False, - fallback="native", - **canvas_kwargs, -): - if fallback not in ("native", "skip", "raster"): - raise ValueError("fallback must be 'native', 'skip', or 'raster'") - if isinstance(source, (str, bytes, os.PathLike)) or hasattr(source, "read"): - if not trusted: - raise ValueError( - "Serialized Matplotlib input requires trusted=True; " - "unpickling can execute arbitrary Python code" - ) - if isinstance(source, bytes): - source = pickle.loads(source) - elif hasattr(source, "read"): - source = pickle.load(source) - else: - with open(source, "rb") as stream: - source = pickle.load(stream) - report = ImportReport() - - def unsupported(message, artist=None): - artist = artist if artist is not None else unsupported.artist - report.add( - artist, - message, - severity="error" if strict else "warning", - fallback="skipped", - ) - if strict: - error = NotImplementedError(message) - error.import_report = report - raise error - warnings.warn(message, UserWarning, stacklevel=3) - - unsupported.artist = source - unsupported.report = report - unsupported.fallback = fallback - - whole_figure = isinstance(source, Figure) - explicit_shape = None - if whole_figure: - axes = list(source.axes) - elif isinstance(source, Axes): - axes = [source] - explicit_shape = (1, 1) - else: - try: - array = np.asarray(source, dtype=object) - except (TypeError, ValueError) as exc: - raise TypeError( - "Expected a Figure, Axes, or rectangular array of Axes" - ) from exc - if array.ndim not in (1, 2): - raise TypeError("Expected a Figure, Axes, or 1D/2D array of Axes") - axes = list(array.flat) - if array.ndim == 2: - explicit_shape = array.shape - if not axes: - raise ValueError("At least one Matplotlib Axes is required") - if not all(isinstance(ax, Axes) for ax in axes): - raise TypeError("Every array element must be a Matplotlib Axes") - if len({id(ax) for ax in axes}) != len(axes): - raise ValueError("Duplicate Axes are not supported") - figure = root_figure(source if whole_figure else axes[0].get_figure()) - if any(root_figure(ax.get_figure()) is not figure for ax in axes): - raise ValueError("All Axes must belong to the same Figure") - - if fallback == "raster": - return _raster_import( - canvas_cls, figure, axes, whole_figure, report, **canvas_kwargs - ) - - # Colorbar axes are decorations, not independent data subplots. - selected = [] - twins = [] - colorbars = [] - # The source figure's creation order identifies the primary axes even when - # the caller passes the twin first in an array. - for index, ax in sorted( - enumerate(axes), key=lambda item: figure.axes.index(item[1]) - ): - if getattr(ax, "_colorbar", None) is not None: - colorbars.append(ax._colorbar) - else: - parent = next( - ( - previous - for _, previous in selected - if ax in previous._twinned_axes.get_siblings(previous) - ), - None, - ) - if parent is None: - selected.append((index, ax)) - else: - direction = "x" if ax.get_shared_x_axes().joined(ax, parent) else "y" - twins.append((parent, ax, direction)) - selected.sort(key=lambda item: item[0]) - if not selected: - raise ValueError("No supported Axes to import") - - if explicit_shape is not None: - nrows, ncols = explicit_shape - positioned = [(i // ncols, i % ncols, ax) for i, ax in selected] - else: - specs = [ax.get_subplotspec() for _, ax in selected] - common_grid = specs[0].get_gridspec() if specs[0] is not None else None - simple_grid = common_grid is not None and all( - spec is not None - and spec.get_gridspec() is common_grid - and spec.rowspan.stop - spec.rowspan.start == 1 - and spec.colspan.stop - spec.colspan.start == 1 - for spec in specs - ) - if simple_grid and len({spec.num1 for spec in specs}) == len(specs): - nrows, ncols = common_grid.get_geometry() - positioned = [ - (spec.rowspan.start, spec.colspan.start, ax) - for spec, (_, ax) in zip(specs, selected) - ] - else: - # Irregular layouts use stable editable slots and retain their - # actual axes rectangles for the Matplotlib renderer. - nrows, ncols = 1, len(selected) - positioned = [(0, i, ax) for i, (_, ax) in enumerate(selected)] - - if "nrows" in canvas_kwargs or "ncols" in canvas_kwargs: - raise TypeError("nrows and ncols are inferred from the imported axes") - canvas_kwargs.setdefault("figsize", tuple(figure.get_size_inches())) - canvas_kwargs.setdefault("dpi", figure.dpi) - canvas = canvas_cls(nrows=nrows, ncols=ncols, **canvas_kwargs) - canvas.import_report = report - canvas._import_layout = {} - canvas._import_in_layout = {} - canvas._import_shared_axes = [] - canvas._import_extra_twins = [] - canvas._import_colorbars = [] - canvas._import_figure_artists = [] - source_targets = {} - source_slots = {} - for row, col, ax in positioned: - target = canvas.add_subplot(row=row, col=col) - source_targets[ax] = target - source_slots[ax] = (row, col) - if explicit_shape is None: - canvas._import_layout[(row, col)] = figure_bounds(ax, figure) - canvas._import_in_layout[(row, col)] = ax.get_in_layout() - _import_axes(ax, target, unsupported) - for parent, twin, direction in twins: - if parent is ax: - factory = canvas.twinx if direction == "x" else canvas.twiny - registry = ( - canvas._twinx_subplots - if direction == "x" - else canvas._twiny_subplots - ) - if (row, col) in registry: - from maxplotlib.subfigure.line_plot import LinePlot - - twin_target = LinePlot() - canvas._import_extra_twins.append( - ((row, col), direction, twin_target) - ) - else: - twin_target = factory(row=row, col=col) - source_targets[twin] = twin_target - _import_axes(twin, twin_target, unsupported) - if explicit_shape is None and all(ax.figure is figure for _, ax in selected): - specs = {source_slots[ax]: ax.get_subplotspec() for _, ax in selected} - canvas._import_layout_specs = ReboundSnapshot(specs, fig=figure) - canvas._import_layout_engine = copy.deepcopy(figure.get_layout_engine()) - if canvas._import_layout_engine is None: - canvas.subplots_adjust( - **{ - name: getattr(figure.subplotpars, name) - for name in ("left", "right", "bottom", "top", "wspace", "hspace") - } - ) - for name in ("x", "y"): - for index, (_, ax) in enumerate(selected): - parent = next( - ( - previous - for _, previous in selected[:index] - if getattr(ax, "get_shared_" + name + "_axes")().joined( - ax, previous - ) - ), - None, - ) - if parent is not None: - canvas._import_shared_axes.append( - (name, source_slots[parent], source_slots[ax]) - ) - for colorbar in colorbars: - owner = getattr(colorbar.mappable, "axes", None) - if owner is not None and owner not in source_targets: - unsupported("Colorbar mappable is outside the selected axes", colorbar.ax) - continue - canvas._import_colorbars.append( - _capture_colorbar(colorbar, source_targets.get(owner)) - ) - report.add(colorbar.ax, "Colorbar is linked to its imported mappable") - if whole_figure: - canvas._import_figure_patch = ReboundSnapshot(figure.patch, fig=figure) - canvas._import_figure_style = dict( - facecolor=figure.get_facecolor(), - edgecolor=figure.get_edgecolor(), - linewidth=figure.patch.get_linewidth(), - frameon=figure.get_frameon(), - ) - for getter, setter in ( - ("get_suptitle", "suptitle"), - ("get_supxlabel", "supxlabel"), - ("get_supylabel", "supylabel"), - ): - value = getattr(figure, getter, lambda: "")() - if value: - text = getattr(figure, "_" + setter) - getattr(canvas, setter)( - value, - x=text.get_position()[0], - y=text.get_position()[1], - **_text_style(text), - ) - titles = {figure._suptitle, figure._supxlabel, figure._supylabel} - for artist in ( - list(figure.legends) - + list(figure.artists) - + list(figure.lines) - + list(figure.patches) - + list(figure.images) - + [text for text in figure.texts if text not in titles] - ): - canvas._import_figure_artists.append(ReboundSnapshot(artist, fig=figure)) - report.add( - artist, "Figure decoration retained for Matplotlib", fallback="native" - ) - - def subfigure_decorations(owner): - for subfigure in owner.subfigs: - background = ReboundSnapshot(subfigure.patch, fig=subfigure) - background.payload.set_zorder( - min((ax.get_zorder() for ax in figure.axes), default=0) - 1 - ) - canvas._import_figure_artists.append(background) - for artist in ( - list(subfigure.texts) - + list(subfigure.legends) - + list(subfigure.artists) - + list(subfigure.lines) - + list(subfigure.patches) - + list(subfigure.images) - ): - canvas._import_figure_artists.append( - ReboundSnapshot(artist, fig=subfigure) - ) - subfigure_decorations(subfigure) - - subfigure_decorations(figure) - return canvas - - def _style(artist): return dict( label=artist.get_label(), @@ -409,6 +140,22 @@ def _text_style(text): return kwargs +def _needs_native_style(artist): + box = artist.get_clip_box() + custom_box = ( + box is not None + and artist.axes is not None + and not np.array_equal(box.bounds, artist.axes.bbox.bounds) + ) + return ( + custom_box + or artist.get_path_effects() + or artist.get_agg_filter() is not None + or artist.get_sketch_params() is not None + or artist.get_clip_path() is not None + ) + + def _import_errorbar(container, ax, target, unsupported): """Return whether the container was consumed as one semantic entry.""" line, caps, ranges = container.lines @@ -509,6 +256,24 @@ def _collection_style(collection, index): return kwargs +def _native(artist, ax, target, unsupported): + if unsupported.fallback == "skip" or not type(artist).__module__.startswith( + ("matplotlib.", "mpl_toolkits.") + ): + unsupported(f"Unsupported {type(artist).__name__}; artist skipped", artist) + return + snapshot = ReboundSnapshot(artist, ax) + target._add( + dict(plot_type="matplotlib_artist", snapshot=snapshot, layer=0, kwargs={}), 0 + ) + unsupported.report.add( + artist, + "Editable artist geometry retained for Matplotlib; " + "other backends require a raster import", + fallback="native", + ) + + def _import_collection(collection, ax, target, unsupported): """Import plain line/polygon collections; return False for scatter.""" if not isinstance(collection, (LineCollection, PolyCollection)): @@ -537,19 +302,77 @@ def _import_collection(collection, ax, target, unsupported): or np.any( ~np.isin(path.codes, [Path.MOVETO, Path.LINETO, Path.CLOSEPOLY]) ) - ): - _native(collection, ax, target, unsupported) - return True - vertices = path.vertices - if path.codes is not None and path.codes[-1] == Path.CLOSEPOLY: - vertices = vertices[:-1] - target.fill( - vertices[:, 0].copy(), - vertices[:, 1].copy(), - hatch=collection.get_hatch(), - **_collection_style(collection, i), + ): + _native(collection, ax, target, unsupported) + return True + vertices = path.vertices + if path.codes is not None and path.codes[-1] == Path.CLOSEPOLY: + vertices = vertices[:-1] + target.fill( + vertices[:, 0].copy(), + vertices[:, 1].copy(), + hatch=collection.get_hatch(), + **_collection_style(collection, i), + ) + return True + + +def _tracked(artists, ax, target, unsupported): + order = {id(artist): i for i, artist in enumerate(ax.get_children())} + for artist in artists: + unsupported.artist = artist + start = len(target.line_data) + yield artist + entries = target.line_data[start:] + children = getattr(artist, "get_children", lambda: [])() + index = order.get( + id(artist), + min( + (order.get(id(child), len(order)) for child in children), + default=len(order), + ), + ) + for entry in entries: + entry["source_artist_id"] = id(artist) + entry["source_container_ids"] = [ + identifier + for identifier, group in target.import_groups.items() + if identifier == id(artist) or id(artist) in group["artist_ids"] + ] + entry.setdefault("source_order", index) + if isinstance(artist, Artist): + entry["source_sticky_edges"] = ( + list(artist.sticky_edges.x), + list(artist.sticky_edges.y), + ) + entry["source_picker"] = artist.get_picker() + if entries: + unsupported.report.add( + artist, + "Imported " + str(len(entries)) + " plot entries", + fallback="geometry" if len(entries) > 1 else None, ) - return True + + +def _capture_inset_indicator(indicator): + """Style for ``indicate_inset_zoom``; connectors are recomputed on render. + + ``InsetIndicator`` spans two Axes and recomputes its rectangle/connector + geometry from live axes limits on every draw, so it cannot be captured as + detached geometry the way single-axes artists are. Recreating it with + ``Axes.indicate_inset_zoom`` on the reconstructed parent/inset pair keeps + that live behavior instead of freezing a stale snapshot. + """ + rectangle = indicator.rectangle + return dict( + facecolor=copy.deepcopy(rectangle.get_facecolor()), + edgecolor=copy.deepcopy(rectangle.get_edgecolor()), + linewidth=rectangle.get_linewidth(), + linestyle=rectangle.get_linestyle(), + alpha=indicator.get_alpha(), + zorder=indicator.get_zorder(), + visible=indicator.get_visible(), + ) def _import_axes(ax, target, unsupported): @@ -870,8 +693,7 @@ def _import_axes(ax, target, unsupported): target.set_ymargin(ax.margins()[1]) unsupported.report.add( ax, - "Axis scales, tick and spine styles, and legend layout " - "retained for Matplotlib", + "Axis scales, tick and spine styles, and legend layout retained for Matplotlib", fallback="native", ) target._import_grid = target._grid @@ -887,130 +709,337 @@ def _import_axes(ax, target, unsupported): entries.sort(key=lambda entry: entry.get("source_order", 0)) -def _needs_native_style(artist): - box = artist.get_clip_box() - custom_box = ( - box is not None - and artist.axes is not None - and not np.array_equal(box.bounds, artist.axes.bbox.bounds) +def _capture_colorbar(colorbar, target): + axis = ( + colorbar.ax.yaxis if colorbar.orientation == "vertical" else colorbar.ax.xaxis ) - return ( - custom_box - or artist.get_path_effects() - or artist.get_agg_filter() is not None - or artist.get_sketch_params() is not None - or artist.get_clip_path() is not None + return dict( + target=target, + artist_id=id(colorbar.mappable), + standalone=( + dict( + norm=copy.deepcopy(colorbar.mappable.norm), + cmap=copy.copy(colorbar.mappable.get_cmap()), + ) + if target is None + else None + ), + position=tuple(colorbar.ax.get_position().bounds), + kwargs=dict( + orientation=colorbar.orientation, + extend=colorbar.extend, + extendfrac=colorbar.extendfrac, + extendrect=colorbar.extendrect, + spacing=colorbar.spacing, + drawedges=colorbar.drawedges, + boundaries=copy.deepcopy(colorbar.boundaries), + values=copy.deepcopy(colorbar.values), + ), + label=axis.label.get_text(), + label_style=_text_style(axis.label), + ticks=copy.deepcopy(colorbar.get_ticks()), + formatter=ReboundSnapshot(colorbar.formatter, colorbar.ax), + state=capture_axis_state(colorbar.ax), ) -def _native(artist, ax, target, unsupported): - if unsupported.fallback == "skip" or not type(artist).__module__.startswith( - ("matplotlib.", "mpl_toolkits.") - ): - unsupported(f"Unsupported {type(artist).__name__}; artist skipped", artist) - return - snapshot = ReboundSnapshot(artist, ax) - target._add( - dict(plot_type="matplotlib_artist", snapshot=snapshot, layer=0, kwargs={}), 0 - ) - unsupported.report.add( - artist, - "Editable artist geometry retained for Matplotlib; " - "other backends require a raster import", - fallback="native", +def _raster_import(canvas_cls, figure, axes, whole_figure, report, **kwargs): + from matplotlib.backends.backend_agg import FigureCanvasAgg + + snapshot = copy.deepcopy(figure) + if not whole_figure: + indices = [figure.axes.index(ax) for ax in axes] + for i, ax in enumerate(list(snapshot.axes)): + if i not in indices: + ax.remove() + FigureCanvasAgg(snapshot).draw() + rgba = np.asarray(snapshot.canvas.buffer_rgba()).copy() + kwargs.setdefault("figsize", tuple(figure.get_size_inches())) + kwargs.setdefault("dpi", figure.dpi) + canvas = canvas_cls(**kwargs) + subplot = canvas.add_subplot(row=0, col=0) + subplot.add_imshow(rgba) + subplot.set_axis_off() + subplot.set_grid(False) + canvas.import_report = report + report.add( + figure, + "Raster snapshot: data, artist editing, vector paths and interactive " + "state are lost", + severity="warning", + fallback="raster", + backends=("matplotlib", "plotly"), ) + return canvas -def _tracked(artists, ax, target, unsupported): - order = {id(artist): i for i, artist in enumerate(ax.get_children())} - for artist in artists: - unsupported.artist = artist - start = len(target.line_data) - yield artist - entries = target.line_data[start:] - children = getattr(artist, "get_children", lambda: [])() - index = order.get( - id(artist), - min( - (order.get(id(child), len(order)) for child in children), - default=len(order), - ), +def import_matplotlib( + canvas_cls, + source, + *, + strict=False, + trusted=False, + fallback="native", + **canvas_kwargs, +): + if fallback not in ("native", "skip", "raster"): + raise ValueError("fallback must be 'native', 'skip', or 'raster'") + if isinstance(source, (str, bytes, os.PathLike)) or hasattr(source, "read"): + if not trusted: + raise ValueError( + "Serialized Matplotlib input requires trusted=True; " + "unpickling can execute arbitrary Python code" + ) + if isinstance(source, bytes): + source = pickle.loads(source) + elif hasattr(source, "read"): + source = pickle.load(source) + else: + with open(source, "rb") as stream: + source = pickle.load(stream) + report = ImportReport() + + def unsupported(message, artist=None): + artist = artist if artist is not None else unsupported.artist + report.add( + artist, + message, + severity="error" if strict else "warning", + fallback="skipped", + ) + if strict: + error = NotImplementedError(message) + error.import_report = report + raise error + warnings.warn(message, UserWarning, stacklevel=3) + + unsupported.artist = source + unsupported.report = report + unsupported.fallback = fallback + + whole_figure = isinstance(source, Figure) + explicit_shape = None + if whole_figure: + axes = list(source.axes) + elif isinstance(source, Axes): + axes = [source] + explicit_shape = (1, 1) + else: + try: + array = np.asarray(source, dtype=object) + except (TypeError, ValueError) as exc: + raise TypeError( + "Expected a Figure, Axes, or rectangular array of Axes" + ) from exc + if array.ndim not in (1, 2): + raise TypeError("Expected a Figure, Axes, or 1D/2D array of Axes") + axes = list(array.flat) + if array.ndim == 2: + explicit_shape = array.shape + if not axes: + raise ValueError("At least one Matplotlib Axes is required") + if not all(isinstance(ax, Axes) for ax in axes): + raise TypeError("Every array element must be a Matplotlib Axes") + if len({id(ax) for ax in axes}) != len(axes): + raise ValueError("Duplicate Axes are not supported") + figure = root_figure(source if whole_figure else axes[0].get_figure()) + if any(root_figure(ax.get_figure()) is not figure for ax in axes): + raise ValueError("All Axes must belong to the same Figure") + + if fallback == "raster": + return _raster_import( + canvas_cls, figure, axes, whole_figure, report, **canvas_kwargs + ) + + # Colorbar axes are decorations, not independent data subplots. + selected = [] + twins = [] + colorbars = [] + # The source figure's creation order identifies the primary axes even when + # the caller passes the twin first in an array. + for index, ax in sorted( + enumerate(axes), key=lambda item: figure.axes.index(item[1]) + ): + if getattr(ax, "_colorbar", None) is not None: + colorbars.append(ax._colorbar) + else: + parent = next( + ( + previous + for _, previous in selected + if ax in previous._twinned_axes.get_siblings(previous) + ), + None, + ) + if parent is None: + selected.append((index, ax)) + else: + direction = "x" if ax.get_shared_x_axes().joined(ax, parent) else "y" + twins.append((parent, ax, direction)) + selected.sort(key=lambda item: item[0]) + if not selected: + raise ValueError("No supported Axes to import") + + if explicit_shape is not None: + nrows, ncols = explicit_shape + positioned = [(i // ncols, i % ncols, ax) for i, ax in selected] + else: + specs = [ax.get_subplotspec() for _, ax in selected] + common_grid = specs[0].get_gridspec() if specs[0] is not None else None + simple_grid = common_grid is not None and all( + spec is not None + and spec.get_gridspec() is common_grid + and spec.rowspan.stop - spec.rowspan.start == 1 + and spec.colspan.stop - spec.colspan.start == 1 + for spec in specs ) - for entry in entries: - entry["source_artist_id"] = id(artist) - entry["source_container_ids"] = [ - identifier - for identifier, group in target.import_groups.items() - if identifier == id(artist) or id(artist) in group["artist_ids"] + if simple_grid and len({spec.num1 for spec in specs}) == len(specs): + nrows, ncols = common_grid.get_geometry() + positioned = [ + (spec.rowspan.start, spec.colspan.start, ax) + for spec, (_, ax) in zip(specs, selected) ] - entry.setdefault("source_order", index) - if isinstance(artist, Artist): - entry["source_sticky_edges"] = ( - list(artist.sticky_edges.x), - list(artist.sticky_edges.y), - ) - entry["source_picker"] = artist.get_picker() - if entries: - unsupported.report.add( - artist, - "Imported " + str(len(entries)) + " plot entries", - fallback="geometry" if len(entries) > 1 else None, - ) + else: + # Irregular layouts use stable editable slots and retain their + # actual axes rectangles for the Matplotlib renderer. + nrows, ncols = 1, len(selected) + positioned = [(0, i, ax) for i, (_, ax) in enumerate(selected)] + if "nrows" in canvas_kwargs or "ncols" in canvas_kwargs: + raise TypeError("nrows and ncols are inferred from the imported axes") + canvas_kwargs.setdefault("figsize", tuple(figure.get_size_inches())) + canvas_kwargs.setdefault("dpi", figure.dpi) + canvas = canvas_cls(nrows=nrows, ncols=ncols, **canvas_kwargs) + canvas.import_report = report + canvas._import_layout = {} + canvas._import_in_layout = {} + canvas._import_shared_axes = [] + canvas._import_extra_twins = [] + canvas._import_colorbars = [] + canvas._import_figure_artists = [] + source_targets = {} + source_slots = {} + for row, col, ax in positioned: + target = canvas.add_subplot(row=row, col=col) + source_targets[ax] = target + source_slots[ax] = (row, col) + if explicit_shape is None: + canvas._import_layout[(row, col)] = figure_bounds(ax, figure) + canvas._import_in_layout[(row, col)] = ax.get_in_layout() + _import_axes(ax, target, unsupported) + for parent, twin, direction in twins: + if parent is ax: + factory = canvas.twinx if direction == "x" else canvas.twiny + registry = ( + canvas._twinx_subplots + if direction == "x" + else canvas._twiny_subplots + ) + if (row, col) in registry: + from maxplotlib.subfigure.line_plot import LinePlot -def _capture_colorbar(colorbar, target): - axis = ( - colorbar.ax.yaxis if colorbar.orientation == "vertical" else colorbar.ax.xaxis - ) - return dict( - target=target, - artist_id=id(colorbar.mappable), - standalone=( - dict( - norm=copy.deepcopy(colorbar.mappable.norm), - cmap=copy.copy(colorbar.mappable.get_cmap()), + twin_target = LinePlot() + canvas._import_extra_twins.append( + ((row, col), direction, twin_target) + ) + else: + twin_target = factory(row=row, col=col) + source_targets[twin] = twin_target + _import_axes(twin, twin_target, unsupported) + if explicit_shape is None and all(ax.figure is figure for _, ax in selected): + specs = {source_slots[ax]: ax.get_subplotspec() for _, ax in selected} + canvas._import_layout_specs = ReboundSnapshot(specs, fig=figure) + canvas._import_layout_engine = copy.deepcopy(figure.get_layout_engine()) + if canvas._import_layout_engine is None: + canvas.subplots_adjust( + **{ + name: getattr(figure.subplotpars, name) + for name in ("left", "right", "bottom", "top", "wspace", "hspace") + } + ) + for name in ("x", "y"): + for index, (_, ax) in enumerate(selected): + parent = next( + ( + previous + for _, previous in selected[:index] + if getattr(ax, "get_shared_" + name + "_axes")().joined( + ax, previous + ) + ), + None, + ) + if parent is not None: + canvas._import_shared_axes.append( + (name, source_slots[parent], source_slots[ax]) + ) + for colorbar in colorbars: + owner = getattr(colorbar.mappable, "axes", None) + if owner is not None and owner not in source_targets: + unsupported("Colorbar mappable is outside the selected axes", colorbar.ax) + continue + canvas._import_colorbars.append( + _capture_colorbar(colorbar, source_targets.get(owner)) + ) + report.add(colorbar.ax, "Colorbar is linked to its imported mappable") + if whole_figure: + canvas._import_figure_patch = ReboundSnapshot(figure.patch, fig=figure) + canvas._import_figure_style = dict( + facecolor=figure.get_facecolor(), + edgecolor=figure.get_edgecolor(), + linewidth=figure.patch.get_linewidth(), + frameon=figure.get_frameon(), + ) + for getter, setter in ( + ("get_suptitle", "suptitle"), + ("get_supxlabel", "supxlabel"), + ("get_supylabel", "supylabel"), + ): + value = getattr(figure, getter, lambda: "")() + if value: + text = getattr(figure, "_" + setter) + getattr(canvas, setter)( + value, + x=text.get_position()[0], + y=text.get_position()[1], + **_text_style(text), + ) + titles = {figure._suptitle, figure._supxlabel, figure._supylabel} + for artist in ( + list(figure.legends) + + list(figure.artists) + + list(figure.lines) + + list(figure.patches) + + list(figure.images) + + [text for text in figure.texts if text not in titles] + ): + canvas._import_figure_artists.append(ReboundSnapshot(artist, fig=figure)) + report.add( + artist, "Figure decoration retained for Matplotlib", fallback="native" ) - if target is None - else None - ), - position=tuple(colorbar.ax.get_position().bounds), - kwargs=dict( - orientation=colorbar.orientation, - extend=colorbar.extend, - extendfrac=colorbar.extendfrac, - extendrect=colorbar.extendrect, - spacing=colorbar.spacing, - drawedges=colorbar.drawedges, - boundaries=copy.deepcopy(colorbar.boundaries), - values=copy.deepcopy(colorbar.values), - ), - label=axis.label.get_text(), - label_style=_text_style(axis.label), - ticks=copy.deepcopy(colorbar.get_ticks()), - formatter=ReboundSnapshot(colorbar.formatter, colorbar.ax), - state=capture_axis_state(colorbar.ax), - ) - -def _capture_inset_indicator(indicator): - """Style for ``indicate_inset_zoom``; connectors are recomputed on render. + def subfigure_decorations(owner): + for subfigure in owner.subfigs: + background = ReboundSnapshot(subfigure.patch, fig=subfigure) + background.payload.set_zorder( + min((ax.get_zorder() for ax in figure.axes), default=0) - 1 + ) + canvas._import_figure_artists.append(background) + for artist in ( + list(subfigure.texts) + + list(subfigure.legends) + + list(subfigure.artists) + + list(subfigure.lines) + + list(subfigure.patches) + + list(subfigure.images) + ): + canvas._import_figure_artists.append( + ReboundSnapshot(artist, fig=subfigure) + ) + subfigure_decorations(subfigure) - ``InsetIndicator`` spans two Axes and recomputes its rectangle/connector - geometry from live axes limits on every draw, so it cannot be captured as - detached geometry the way single-axes artists are. Recreating it with - ``Axes.indicate_inset_zoom`` on the reconstructed parent/inset pair keeps - that live behavior instead of freezing a stale snapshot. - """ - rectangle = indicator.rectangle - return dict( - facecolor=copy.deepcopy(rectangle.get_facecolor()), - edgecolor=copy.deepcopy(rectangle.get_edgecolor()), - linewidth=rectangle.get_linewidth(), - linestyle=rectangle.get_linestyle(), - alpha=indicator.get_alpha(), - zorder=indicator.get_zorder(), - visible=indicator.get_visible(), - ) + subfigure_decorations(figure) + return canvas def _import_child(child, ax, target, unsupported): @@ -1046,33 +1075,3 @@ def _import_child(child, ax, target, unsupported): unsupported.report.add( child, "Child axes retained for Matplotlib", fallback="native" ) - - -def _raster_import(canvas_cls, figure, axes, whole_figure, report, **kwargs): - from matplotlib.backends.backend_agg import FigureCanvasAgg - - snapshot = copy.deepcopy(figure) - if not whole_figure: - indices = [figure.axes.index(ax) for ax in axes] - for i, ax in enumerate(list(snapshot.axes)): - if i not in indices: - ax.remove() - FigureCanvasAgg(snapshot).draw() - rgba = np.asarray(snapshot.canvas.buffer_rgba()).copy() - kwargs.setdefault("figsize", tuple(figure.get_size_inches())) - kwargs.setdefault("dpi", figure.dpi) - canvas = canvas_cls(**kwargs) - subplot = canvas.add_subplot(row=0, col=0) - subplot.add_imshow(rgba) - subplot.set_axis_off() - subplot.set_grid(False) - canvas.import_report = report - report.add( - figure, - "Raster snapshot: data, artist editing, vector paths and interactive " - "state are lost", - severity="warning", - fallback="raster", - backends=("matplotlib", "plotly"), - ) - return canvas diff --git a/src/maxplotlib/backends/plotext/figure.py b/src/maxplotlib/backends/plotext/figure.py index 49c00c5..fdefbbe 100644 --- a/src/maxplotlib/backends/plotext/figure.py +++ b/src/maxplotlib/backends/plotext/figure.py @@ -16,9 +16,6 @@ def strip_ansi(text: str) -> str: class _Plotext6Axes: """Small compatibility surface for maxplotlib's plotext renderer.""" - def __init__(self, figure): - self._figure = figure - def _draw(self, signal, label=None): if label is not None: signal.label(label) @@ -30,6 +27,9 @@ def plot(self, x, y, **kwargs): scatter = plot + def __init__(self, figure): + self._figure = figure + def bar(self, *args, **kwargs): label = kwargs.pop("label", None) kwargs.pop("color", None) @@ -116,6 +116,15 @@ def create_plotext_figure(nrows: int = 1, ncols: int = 1): class PlotextFigure: + 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 __init__(self, figure: Any, suptitle: str | None = None): self.figure = figure self.suptitle = suptitle @@ -131,15 +140,6 @@ def show(self) -> str: 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) diff --git a/src/maxplotlib/backends/tikzfigure/convert.py b/src/maxplotlib/backends/tikzfigure/convert.py index d787777..3122f5e 100644 --- a/src/maxplotlib/backends/tikzfigure/convert.py +++ b/src/maxplotlib/backends/tikzfigure/convert.py @@ -42,6 +42,7 @@ from matplotlib.path import Path from matplotlib.text import Annotation, Text from matplotlib.transforms import Bbox + from tikzfigure import TikzFigure from tikzfigure.core.axis import Axis2D from tikzfigure.core.plot import format_number @@ -104,63 +105,18 @@ class TikzConversionWarning(UserWarning): ) -def figure_to_tikz( - figure, - *, - raster_dpi: float = 300, - max_markers: int = 2000, - max_items: int = 500, - max_points: int = 20000, - precision: int = 6, -) -> TikzFigure: - """Convert a Matplotlib figure into a :class:`~tikzfigure.TikzFigure`. - - The figure is drawn first (without showing it), so that its layout, - autoscaled limits, ticks and legend positions are final, and is left - as it was afterwards. - - Parameters - ---------- - figure : matplotlib.figure.Figure - The figure to convert. - raster_dpi : float, optional - Resolution of the parts drawn as images (meshes, images, ...). - Default: 300. - max_markers : int, optional - Scatter plots with more points are drawn as an image. Default: 2000. - max_items : int, optional - Collections with more differently styled items (e.g. a line - collection colored by value) are drawn as an image. Default: 500. - max_points : int, optional - Lines with more points, after Matplotlib's path simplification, are - drawn as an image. Default: 20000. - precision : int, optional - Significant digits of the written coordinates. Default: 6. - - Returns - ------- - tikzfigure.TikzFigure - The figure: ``.generate_tikz()`` for the code, ``.savefig("f.pdf")`` - to compile it, ``.savefig("f.tikz")`` to write the code and its - images. - """ - converter = _FigureConverter( - figure, - raster_dpi=raster_dpi, - max_markers=max_markers, - max_items=max_items, - max_points=max_points, - precision=precision, - ) - return converter.convert() - - class _Colors: """Colors used in the figure, defined once as ``\\definecolor``.""" def __init__(self): self.names: dict[str, str] = {} + def definitions(self) -> str: + return "\n".join( + f"\\definecolor{{{name}}}{{HTML}}{{{code}}}" + for code, name in self.names.items() + ) + def __call__(self, color): """``(name, opacity)`` of a color; name ``None`` for a transparent one.""" rgba = mcolors.to_rgba(color) @@ -170,12 +126,6 @@ def __call__(self, color): name = self.names.setdefault(code, f"mpl{code}") return name, float(rgba[3]) - def definitions(self) -> str: - return "\n".join( - f"\\definecolor{{{name}}}{{HTML}}{{{code}}}" - for code, name in self.names.items() - ) - def _font(size) -> str: size = float(size) @@ -186,20 +136,73 @@ def _pt(value) -> str: return f"{float(value):.4g}pt" -def _dash(pattern) -> str | None: - """A Matplotlib ``(offset, [on, off, ...])`` dash pattern in points as TikZ.""" - if pattern is None: - return None - offset, sequence = pattern - if not sequence: - return None - parts = [] - for index, length in enumerate(sequence): - parts.append(("on" if index % 2 == 0 else "off") + " " + _pt(length)) - option = "dash pattern=" + " ".join(parts) - if offset: - option += f", dash phase={_pt(offset)}" - return option +def _all_axes(figure): + out = [] + + def walk(ax): + out.append(ax) + for child in ax.child_axes: + walk(child) + + for ax in figure.axes: + walk(ax) + return out + + +def _supported_scales(ax) -> bool: + return ax.get_xscale() in ("linear", "log") and ax.get_yscale() in ( + "linear", + "log", + ) + + +def _text_display_position(text): + return text.get_transform().transform(text.get_unitless_position()) + + +def _anchor(text) -> str: + vertical = _VA.get(text.get_va(), "") + horizontal = _HA.get(text.get_ha(), "") + if vertical == "base" and not horizontal: + return "base" + if vertical == "mid" and not horizontal: + return "mid" + anchor = " ".join(part for part in (vertical, horizontal) if part) + return anchor or "center" + + +def _text_node(text, colors, at: str) -> str: + """A ``\\node`` for a Matplotlib text, placed at the TikZ point ``at``.""" + options = [f"anchor={_anchor(text)}", "inner sep=0pt"] + rotation = text.get_rotation() + if rotation: + options.append(f"rotate={rotation:g}") + color, opacity = colors(text.get_color()) + if color is not None and color != "mpl000000": + options.append(f"text={color}") + alpha = text.get_alpha() + if alpha is not None and alpha < 1: + opacity *= alpha + if opacity < 1: + options.append(f"text opacity={opacity:.3g}") + options.append(f"font={{{_font(text.get_fontsize())}}}") + if is_multiline(text.get_text()): + alignment = getattr(text, "_multialignment", None) or text.get_ha() + options.append(f"align={alignment}") + box = text.get_bbox_patch() + if box is not None: + face, face_opacity = colors(box.get_facecolor()) + edge, _ = colors(box.get_edgecolor()) + options[1] = "inner sep=2pt" + if face is not None: + options.append(f"fill={face}") + if face_opacity < 1: + options.append(f"fill opacity={face_opacity:.3g}, text opacity=1") + if edge is not None and box.get_linewidth() > 0: + options.append(f"draw={edge}, line width={_pt(box.get_linewidth())}") + if "round" in type(box.get_boxstyle()).__name__.lower(): + options.append("rounded corners=2pt") + return f"\\node[{', '.join(options)}] at {at} {{{latex(text.get_text())}}};" class _FigureConverter: @@ -210,58 +213,6 @@ def __init__(self, figure, **options): self.tikz = TikzFigure() self.width, self.height = figure.get_size_inches() - # -- the figure ---------------------------------------------------------- - def convert(self) -> TikzFigure: - figure = self.figure - figure.draw_without_rendering() - # images are rendered from this figure; a layout engine would move the - # axes while other parts are hidden - engine = figure.get_layout_engine() - figure.set_layout_engine("none") - try: - for ax in figure.axes: - self._axes_tree(ax) - for text in figure.texts: - self._figure_text(text) - for legend in figure.legends: - if legend.get_visible(): - self._as_image(legend, "a figure legend") - finally: - figure._layout_engine = engine - self.tikz.add_package("lmodern") - self.tikz.add_package("amsmath") - definitions = self.colors.definitions() - if definitions: - self.tikz.add_raw(definitions) - return self.tikz - - def _axes_tree(self, ax): - if not ax.get_visible(): - return - if ax.name != "rectilinear" or not _supported_scales(ax): - what = ( - f"{ax.name} axes" - if ax.name != "rectilinear" - else f"{ax.get_xscale()}/{ax.get_yscale()} scaled axes" - ) - self._as_image(ax, what) - return - _AxesConverter(self, ax).convert() - for child in ax.child_axes: - self._axes_tree(child) - - def _figure_text(self, text): - if not text.get_visible() or not text.get_text().strip(): - return - if isinstance(text, Annotation): - text.update_positions(self.figure._get_renderer()) - x, y = _text_display_position(text) - if not self.figure.bbox.contains(x, y): - return - x_in, y_in = x / self.figure.dpi, y / self.figure.dpi - node = _text_node(text, self.colors, f"({x_in:.4f}in,{y_in:.4f}in)") - self.tikz.add_raw(node) - # -- images of whole parts --------------------------------------------- def _as_image(self, artist, what): """Draw ``artist`` (axes or legend) with its decorations as one image.""" @@ -292,6 +243,58 @@ def _as_image(self, artist, what): axis.add_graphics(0, 1, 0, 1, data=data, plot_options=["forget plot"]) self.tikz.axes.append(axis) + def _axes_tree(self, ax): + if not ax.get_visible(): + return + if ax.name != "rectilinear" or not _supported_scales(ax): + what = ( + f"{ax.name} axes" + if ax.name != "rectilinear" + else f"{ax.get_xscale()}/{ax.get_yscale()} scaled axes" + ) + self._as_image(ax, what) + return + _AxesConverter(self, ax).convert() + for child in ax.child_axes: + self._axes_tree(child) + + def _figure_text(self, text): + if not text.get_visible() or not text.get_text().strip(): + return + if isinstance(text, Annotation): + text.update_positions(self.figure._get_renderer()) + x, y = _text_display_position(text) + if not self.figure.bbox.contains(x, y): + return + x_in, y_in = x / self.figure.dpi, y / self.figure.dpi + node = _text_node(text, self.colors, f"({x_in:.4f}in,{y_in:.4f}in)") + self.tikz.add_raw(node) + + # -- the figure ---------------------------------------------------------- + def convert(self) -> TikzFigure: + figure = self.figure + figure.draw_without_rendering() + # images are rendered from this figure; a layout engine would move the + # axes while other parts are hidden + engine = figure.get_layout_engine() + figure.set_layout_engine("none") + try: + for ax in figure.axes: + self._axes_tree(ax) + for text in figure.texts: + self._figure_text(text) + for legend in figure.legends: + if legend.get_visible(): + self._as_image(legend, "a figure legend") + finally: + figure._layout_engine = engine + self.tikz.add_package("lmodern") + self.tikz.add_package("amsmath") + definitions = self.colors.definitions() + if definitions: + self.tikz.add_raw(definitions) + return self.tikz + def render_only(self, artists, inches: Bbox, keep_axes=None) -> bytes: """A PNG of the figure region ``inches`` showing only ``artists``. @@ -356,73 +359,99 @@ def hide(artist): return buffer.getvalue() -def _all_axes(figure): - out = [] - - def walk(ax): - out.append(ax) - for child in ax.child_axes: - walk(child) +def figure_to_tikz( + figure, + *, + raster_dpi: float = 300, + max_markers: int = 2000, + max_items: int = 500, + max_points: int = 20000, + precision: int = 6, +) -> TikzFigure: + """Convert a Matplotlib figure into a :class:`~tikzfigure.TikzFigure`. - for ax in figure.axes: - walk(ax) - return out + The figure is drawn first (without showing it), so that its layout, + autoscaled limits, ticks and legend positions are final, and is left + as it was afterwards. + Parameters + ---------- + figure : matplotlib.figure.Figure + The figure to convert. + raster_dpi : float, optional + Resolution of the parts drawn as images (meshes, images, ...). + Default: 300. + max_markers : int, optional + Scatter plots with more points are drawn as an image. Default: 2000. + max_items : int, optional + Collections with more differently styled items (e.g. a line + collection colored by value) are drawn as an image. Default: 500. + max_points : int, optional + Lines with more points, after Matplotlib's path simplification, are + drawn as an image. Default: 20000. + precision : int, optional + Significant digits of the written coordinates. Default: 6. -def _supported_scales(ax) -> bool: - return ax.get_xscale() in ("linear", "log") and ax.get_yscale() in ( - "linear", - "log", + Returns + ------- + tikzfigure.TikzFigure + The figure: ``.generate_tikz()`` for the code, ``.savefig("f.pdf")`` + to compile it, ``.savefig("f.tikz")`` to write the code and its + images. + """ + converter = _FigureConverter( + figure, + raster_dpi=raster_dpi, + max_markers=max_markers, + max_items=max_items, + max_points=max_points, + precision=precision, ) + return converter.convert() -def _text_display_position(text): - return text.get_transform().transform(text.get_unitless_position()) +def _dash(pattern) -> str | None: + """A Matplotlib ``(offset, [on, off, ...])`` dash pattern in points as TikZ.""" + if pattern is None: + return None + offset, sequence = pattern + if not sequence: + return None + parts = [] + for index, length in enumerate(sequence): + parts.append(("on" if index % 2 == 0 else "off") + " " + _pt(length)) + option = "dash pattern=" + " ".join(parts) + if offset: + option += f", dash phase={_pt(offset)}" + return option -def _anchor(text) -> str: - vertical = _VA.get(text.get_va(), "") - horizontal = _HA.get(text.get_ha(), "") - if vertical == "base" and not horizontal: - return "base" - if vertical == "mid" and not horizontal: - return "mid" - anchor = " ".join(part for part in (vertical, horizontal) if part) - return anchor or "center" +# artists drawn as images without a warning: images, meshes, filled contours, +# quivers and the like have no better form in pgfplots +_ALWAYS_RASTER = (AxesImage, Collection) -def _text_node(text, colors, at: str) -> str: - """A ``\\node`` for a Matplotlib text, placed at the TikZ point ``at``.""" - options = [f"anchor={_anchor(text)}", "inner sep=0pt"] - rotation = text.get_rotation() - if rotation: - options.append(f"rotate={rotation:g}") - color, opacity = colors(text.get_color()) - if color is not None and color != "mpl000000": - options.append(f"text={color}") - alpha = text.get_alpha() - if alpha is not None and alpha < 1: - opacity *= alpha - if opacity < 1: - options.append(f"text opacity={opacity:.3g}") - options.append(f"font={{{_font(text.get_fontsize())}}}") - if is_multiline(text.get_text()): - alignment = getattr(text, "_multialignment", None) or text.get_ha() - options.append(f"align={alignment}") - box = text.get_bbox_patch() - if box is not None: - face, face_opacity = colors(box.get_facecolor()) - edge, _ = colors(box.get_edgecolor()) - options[1] = "inner sep=2pt" - if face is not None: - options.append(f"fill={face}") - if face_opacity < 1: - options.append(f"fill opacity={face_opacity:.3g}, text opacity=1") - if edge is not None and box.get_linewidth() > 0: - options.append(f"draw={edge}, line width={_pt(box.get_linewidth())}") - if "round" in type(box.get_boxstyle()).__name__.lower(): - options.append("rounded corners=2pt") - return f"\\node[{', '.join(options)}] at {at} {{{latex(text.get_text())}}};" +def _marker_name(path) -> str: + for marker in _MARKS: + style = MarkerStyle(marker) + candidate = style.get_path().transformed(style.get_transform()) + if candidate.vertices.shape == path.vertices.shape and np.allclose( + candidate.vertices, path.vertices + ): + return marker + return "o" + + +def _with_gaps(path): + """The vertices of a path as one array, its separate pieces joined by nan.""" + out = [] + for vertex, code in zip(path.vertices, path.codes): + if code == Path.STOP: + break + if code == Path.MOVETO and out: + out.append((np.nan, np.nan)) + out.append(tuple(vertex)) + return np.asarray(out, dtype=float).reshape(-1, 2) class _AxesConverter: @@ -438,60 +467,6 @@ def __init__(self, parent: _FigureConverter, ax): self.legend_entries: list = [] self.has_images = False - # -- the axis ---------------------------------------------------------- - def convert(self): - ax = self.ax - position = ax.get_position() - width, height = self.parent.width, self.parent.height - x0, y0 = position.x0 * width, position.y0 * height - self.size = (position.width * width, position.height * height) - self.inches = Bbox.from_bounds(x0, y0, *self.size) - xlim, ylim = ax.get_xlim(), ax.get_ylim() - - options = [ - "scale only axis", - "anchor=south west", - f"at={{({x0:.4f}in,{y0:.4f}in)}}", - "clip mode=individual", - "unbounded coords=jump", - "every axis plot/.append style={line join=round}", - ] - if xlim[0] > xlim[1]: - options.append("x dir=reverse") - if ylim[0] > ylim[1]: - options.append("y dir=reverse") - if not ax.axison: - options.append("hide axis") - else: - options.extend(self._frame()) - for name in ("x", "y"): - options.extend(self._ticks(name)) - options.extend(self._labels()) - options.extend(self._background()) - - self.axis = Axis2D( - xlabel=latex(ax.get_xlabel()) if ax.xaxis.get_visible() else "", - ylabel=latex(ax.get_ylabel()) if ax.yaxis.get_visible() else "", - title=latex(self._title()), - xlim=(float(min(xlim)), float(max(xlim))), - ylim=(float(min(ylim)), float(max(ylim))), - xlog=ax.get_xscale() == "log", - ylog=ax.get_yscale() == "log", - grid=None, - width=f"{self.size[0]:.4f}in", - height=f"{self.size[1]:.4f}in", - options=options, - ) - self._legend_setup() - self._contents() - self._legend_entries() - if self.has_images: - self.axis.options.append("axis on top") - grid = self._grid() - if grid: - self.axis.options.extend(grid) - self.parent.tikz.axes.append(self.axis) - def _title(self) -> str: for location in ("center", "left", "right"): title = self.ax.get_title(location) @@ -513,12 +488,20 @@ def _frame(self) -> list[str]: x = ( "box" if visible["bottom"] and visible["top"] - else "bottom" if visible["bottom"] else "top" if visible["top"] else None + else "bottom" + if visible["bottom"] + else "top" + if visible["top"] + else None ) y = ( "box" if visible["left"] and visible["right"] - else "left" if visible["left"] else "right" if visible["right"] else None + else "left" + if visible["left"] + else "right" + if visible["right"] + else None ) options.append(f"axis x line*={x}" if x else "axis x line=none") options.append(f"axis y line*={y}" if y else "axis y line=none") @@ -684,19 +667,97 @@ def _legend_label(self, artist) -> str: """Plots never make legend entries themselves; see :meth:`_legend_entries`.""" return "" - def _legend_entries(self): - """The legend as Matplotlib draws it: an image and a text per entry. + def _stroke(self, color, linewidth, dash, alpha=None) -> list[str] | None: + name, opacity = self.colors(color) + if name is None or linewidth <= 0: + return None + if alpha is not None: + opacity *= alpha + options = [f"draw={name}", f"line width={_pt(linewidth)}"] + if opacity < 1: + options.append(f"draw opacity={opacity:.3g}") + options.append(dash or "solid") + return options - The plots are left out of the legend (``forget plot``), so that the - entries keep the legend's order and style, also for artists drawn as - images. - """ - for handle, label in self.legend_entries: - options = self._legend_image(handle) - self.axis.add_raw( - f"\\addlegendimage{{{', '.join(options)}}}\n" - f"\\addlegendentry{{{latex(label)}}}" + def _fill(self, color, alpha=None) -> list[str] | None: + name, opacity = self.colors(color) + if name is None: + return None + if alpha is not None: + opacity *= alpha + options = [f"fill={name}"] + if opacity < 1: + options.append(f"fill opacity={opacity:.3g}") + return options + + def _mark(self, marker, size, face, edge, edge_width, alpha=None) -> list[str]: + """``mark=...`` options for a Matplotlib marker of ``size`` points.""" + if isinstance(marker, str) and marker in _MARKS: + filled_mark, hollow_mark, rotation, factor = _MARKS[marker] + else: + filled_mark, hollow_mark, rotation, factor = _MARKS["o"] + fill = self._fill(face, alpha) + mark = filled_mark if fill else hollow_mark + mark_options = ["solid"] + if rotation: + mark_options.append(f"rotate={rotation}") + mark_options.extend(fill or ["fill=none"]) + stroke = self._stroke(edge, edge_width, None, alpha) + if stroke: + mark_options.extend(option for option in stroke if option != "solid") + else: + mark_options.append("draw=none") + return [ + f"mark={mark}", + f"mark size={_pt(max(size * factor, 0.1))}", + f"mark options={{{', '.join(mark_options)}}}", + ] + + def _line_options(self, line) -> list[str] | None: + """The style of a Line2D as plot options; None if it draws nothing.""" + marker = line.get_marker() + has_marker = marker not in _NO_MARKER and line.get_markersize() > 0 + has_line = line.get_linestyle() not in _NO_LINE and line.get_linewidth() > 0 + alpha = line.get_alpha() + options = [] + if has_line: + dash = _dash(line._dash_pattern) if line.is_dashed() else None + stroke = self._stroke(line.get_color(), line.get_linewidth(), dash, alpha) + if stroke is None: + has_line = False + else: + options.extend(stroke) + if not has_line: + if not has_marker: + return None + options.append("only marks") + if has_marker: + face = line.get_markerfacecolor() + if line.get_fillstyle() == "none": + face = "none" + options.extend( + self._mark( + marker, + line.get_markersize(), + face, + line.get_markeredgecolor(), + line.get_markeredgewidth(), + alpha, + ) ) + else: + options.append("mark=none") + return options + + def _area_options(self, face, edge, width, dash, alpha=None): + fill = self._fill(face, alpha) + stroke = self._stroke(edge, width, dash, alpha) + if fill is None and stroke is None: + return None + options = list(fill or ["fill=none"]) + options.extend(stroke or ["draw=none"]) + options.extend(["mark=none", "area legend"]) + return options def _legend_image(self, handle) -> list[str]: if isinstance(handle, Line2D): @@ -740,31 +801,19 @@ def _legend_image(self, handle) -> list[str]: return stroke + ["mark=none"] return ["empty legend"] - # -- what is drawn ----------------------------------------------------- - def _contents(self): - ax = self.ax - children = list(getattr(ax, "_children", [])) - if not children: # an older Matplotlib - children = ( - ax.collections - + ax.patches - + ax.lines - + ax.texts - + ax.images - + ax.tables + def _legend_entries(self): + """The legend as Matplotlib draws it: an image and a text per entry. + + The plots are left out of the legend (``forget plot``), so that the + entries keep the legend's order and style, also for artists drawn as + images. + """ + for handle, label in self.legend_entries: + options = self._legend_image(handle) + self.axis.add_raw( + f"\\addlegendimage{{{', '.join(options)}}}\n" + f"\\addlegendentry{{{latex(label)}}}" ) - children = [child for child in children if child.get_visible()] - children.sort(key=lambda artist: artist.get_zorder()) # stable: drawing order - pending_images = [] - for artist in children: - emit = None if self.colorbar is not None else self._vector(artist) - if emit is None: - pending_images.append(artist) - continue - self._flush_images(pending_images) - pending_images = [] - emit() - self._flush_images(pending_images) def _flush_images(self, artists): if not artists: @@ -794,26 +843,6 @@ def _flush_images(self, artists): ) self.has_images = True - def _vector(self, artist): - """A function adding ``artist`` to the axis as vector graphics, or None.""" - if isinstance(artist, Line2D): - return self._line(artist) - if isinstance(artist, ContourSet): - return self._contour_lines(artist) - if isinstance(artist, PathCollection): - return self._scatter(artist) - if isinstance(artist, LineCollection): - return self._line_collection(artist) - if type(artist) is PolyCollection or type(artist).__name__ in ( - "FillBetweenPolyCollection", - ): - return self._polygons(artist) - if isinstance(artist, Patch): - return self._patch(artist) - if isinstance(artist, Text): - return self._text(artist) - return None - # coordinates def _view(self): """The axes box in display coordinates, grown by one box size on each side. @@ -880,20 +909,6 @@ def _to_data(self, display): data[np.abs(data[:, index]) < tiny, index] = 0.0 return data - def _add(self, data, options, label="", cycle=False, clip=True): - data = np.asarray(data, dtype=float) - if not clip: - self.axis.add_raw(self._draw(data, options, cycle)) - return - self.axis.add_plot( - x=data[:, 0].tolist(), - y=data[:, 1].tolist(), - label=label, - options=list(options) + ["forget plot"], - cycle=cycle, - precision=self.options["precision"], - ) - def _draw(self, data, options, cycle) -> str: """A ``\\draw`` path in axis coordinates, which pgfplots does not clip.""" precision = self.options["precision"] @@ -917,51 +932,19 @@ def _draw(self, data, options, cycle) -> str: parts.append("-- cycle") return f"\\draw[{', '.join(keep)}] {' '.join(parts)};" - def _stroke(self, color, linewidth, dash, alpha=None) -> list[str] | None: - name, opacity = self.colors(color) - if name is None or linewidth <= 0: - return None - if alpha is not None: - opacity *= alpha - options = [f"draw={name}", f"line width={_pt(linewidth)}"] - if opacity < 1: - options.append(f"draw opacity={opacity:.3g}") - options.append(dash or "solid") - return options - - def _fill(self, color, alpha=None) -> list[str] | None: - name, opacity = self.colors(color) - if name is None: - return None - if alpha is not None: - opacity *= alpha - options = [f"fill={name}"] - if opacity < 1: - options.append(f"fill opacity={opacity:.3g}") - return options - - def _mark(self, marker, size, face, edge, edge_width, alpha=None) -> list[str]: - """``mark=...`` options for a Matplotlib marker of ``size`` points.""" - if isinstance(marker, str) and marker in _MARKS: - filled_mark, hollow_mark, rotation, factor = _MARKS[marker] - else: - filled_mark, hollow_mark, rotation, factor = _MARKS["o"] - fill = self._fill(face, alpha) - mark = filled_mark if fill else hollow_mark - mark_options = ["solid"] - if rotation: - mark_options.append(f"rotate={rotation}") - mark_options.extend(fill or ["fill=none"]) - stroke = self._stroke(edge, edge_width, None, alpha) - if stroke: - mark_options.extend(option for option in stroke if option != "solid") - else: - mark_options.append("draw=none") - return [ - f"mark={mark}", - f"mark size={_pt(max(size * factor, 0.1))}", - f"mark options={{{', '.join(mark_options)}}}", - ] + def _add(self, data, options, label="", cycle=False, clip=True): + data = np.asarray(data, dtype=float) + if not clip: + self.axis.add_raw(self._draw(data, options, cycle)) + return + self.axis.add_plot( + x=data[:, 0].tolist(), + y=data[:, 1].tolist(), + label=label, + options=list(options) + ["forget plot"], + cycle=cycle, + precision=self.options["precision"], + ) # artists def _line(self, line): @@ -985,41 +968,80 @@ def _line(self, line): clip = line.get_clip_on() return lambda: self._add(data, options, label, clip=clip) - def _line_options(self, line) -> list[str] | None: - """The style of a Line2D as plot options; None if it draws nothing.""" - marker = line.get_marker() - has_marker = marker not in _NO_MARKER and line.get_markersize() > 0 - has_line = line.get_linestyle() not in _NO_LINE and line.get_linewidth() > 0 - alpha = line.get_alpha() - options = [] - if has_line: - dash = _dash(line._dash_pattern) if line.is_dashed() else None - stroke = self._stroke(line.get_color(), line.get_linewidth(), dash, alpha) - if stroke is None: - has_line = False - else: - options.extend(stroke) - if not has_line: - if not has_marker: + def _patch(self, patch, clip=None): + if clip is None: + clip = patch.get_clip_on() + transform = patch.get_transform() + path = patch.get_path() + fillable = None + if isinstance(patch, FancyArrowPatch): + try: + paths, fillable = patch._get_path_in_displaycoord() + except Exception: # pragma: no cover - private Matplotlib API + return None + if not np.iterable(fillable): + paths, fillable = [paths], [fillable] + transform = None + else: + paths, fillable = [path], [True] + # the patch's colors include its alpha + face = patch.get_facecolor() if patch.get_fill() else "none" + dash = _dash(patch._dash_pattern) + pieces = [] + for sub_path, can_fill in zip(paths, fillable): + if transform is not None: + sub_path = transform.transform_path(sub_path) + polygons = sub_path.to_polygons(closed_only=False) + options = self._area_options( + face if can_fill else "none", + patch.get_edgecolor(), + patch.get_linewidth(), + dash, + ) + if options is None: + continue + for polygon in polygons: + closed = can_fill and len(polygon) > 2 + polygon = ( + self._clip_polygon(polygon) if closed else self._clip_line(polygon) + ) + if len(polygon): + pieces.append((self._to_data(polygon), options, closed)) + label = self._legend_label(patch) + + def emit(): + for index, (data, options, closed) in enumerate(pieces): + self._add( + data, options, label if index == 0 else "", cycle=closed, clip=clip + ) + + return emit + + def _text(self, text): + if not text.get_text().strip(): + return lambda: None + if isinstance(text, Annotation): + text.update_positions(self.figure._get_renderer()) + arrow = ( + getattr(text, "arrow_patch", None) if isinstance(text, Annotation) else None + ) + arrow_emit = None + if arrow is not None and arrow.get_visible(): + arrow_emit = self._patch(arrow, clip=False) + if arrow_emit is None: return None - options.append("only marks") - if has_marker: - face = line.get_markerfacecolor() - if line.get_fillstyle() == "none": - face = "none" - options.extend( - self._mark( - marker, - line.get_markersize(), - face, - line.get_markeredgecolor(), - line.get_markeredgewidth(), - alpha, - ) - ) - else: - options.append("mark=none") - return options + display = _text_display_position(text) + if not self.figure.bbox.contains(*display): + return arrow_emit or (lambda: None) + fx, fy = self.ax.transAxes.inverted().transform(display) + node = _text_node(text, self.colors, f"(axis description cs:{fx:.5g},{fy:.5g})") + + def emit(): + if arrow_emit is not None: + arrow_emit() + self.axis.add_raw(node) + + return emit def _contour_lines(self, contours): if contours.filled: @@ -1217,115 +1239,102 @@ def emit(): return emit - def _area_options(self, face, edge, width, dash, alpha=None): - fill = self._fill(face, alpha) - stroke = self._stroke(edge, width, dash, alpha) - if fill is None and stroke is None: - return None - options = list(fill or ["fill=none"]) - options.extend(stroke or ["draw=none"]) - options.extend(["mark=none", "area legend"]) - return options + def _vector(self, artist): + """A function adding ``artist`` to the axis as vector graphics, or None.""" + if isinstance(artist, Line2D): + return self._line(artist) + if isinstance(artist, ContourSet): + return self._contour_lines(artist) + if isinstance(artist, PathCollection): + return self._scatter(artist) + if isinstance(artist, LineCollection): + return self._line_collection(artist) + if type(artist) is PolyCollection or type(artist).__name__ in ( + "FillBetweenPolyCollection", + ): + return self._polygons(artist) + if isinstance(artist, Patch): + return self._patch(artist) + if isinstance(artist, Text): + return self._text(artist) + return None - def _patch(self, patch, clip=None): - if clip is None: - clip = patch.get_clip_on() - transform = patch.get_transform() - path = patch.get_path() - fillable = None - if isinstance(patch, FancyArrowPatch): - try: - paths, fillable = patch._get_path_in_displaycoord() - except Exception: # pragma: no cover - private Matplotlib API - return None - if not np.iterable(fillable): - paths, fillable = [paths], [fillable] - transform = None - else: - paths, fillable = [path], [True] - # the patch's colors include its alpha - face = patch.get_facecolor() if patch.get_fill() else "none" - dash = _dash(patch._dash_pattern) - pieces = [] - for sub_path, can_fill in zip(paths, fillable): - if transform is not None: - sub_path = transform.transform_path(sub_path) - polygons = sub_path.to_polygons(closed_only=False) - options = self._area_options( - face if can_fill else "none", - patch.get_edgecolor(), - patch.get_linewidth(), - dash, + # -- what is drawn ----------------------------------------------------- + def _contents(self): + ax = self.ax + children = list(getattr(ax, "_children", [])) + if not children: # an older Matplotlib + children = ( + ax.collections + + ax.patches + + ax.lines + + ax.texts + + ax.images + + ax.tables ) - if options is None: + children = [child for child in children if child.get_visible()] + children.sort(key=lambda artist: artist.get_zorder()) # stable: drawing order + pending_images = [] + for artist in children: + emit = None if self.colorbar is not None else self._vector(artist) + if emit is None: + pending_images.append(artist) continue - for polygon in polygons: - closed = can_fill and len(polygon) > 2 - polygon = ( - self._clip_polygon(polygon) if closed else self._clip_line(polygon) - ) - if len(polygon): - pieces.append((self._to_data(polygon), options, closed)) - label = self._legend_label(patch) + self._flush_images(pending_images) + pending_images = [] + emit() + self._flush_images(pending_images) - def emit(): - for index, (data, options, closed) in enumerate(pieces): - self._add( - data, options, label if index == 0 else "", cycle=closed, clip=clip - ) + # -- the axis ---------------------------------------------------------- + def convert(self): + ax = self.ax + position = ax.get_position() + width, height = self.parent.width, self.parent.height + x0, y0 = position.x0 * width, position.y0 * height + self.size = (position.width * width, position.height * height) + self.inches = Bbox.from_bounds(x0, y0, *self.size) + xlim, ylim = ax.get_xlim(), ax.get_ylim() - return emit + options = [ + "scale only axis", + "anchor=south west", + f"at={{({x0:.4f}in,{y0:.4f}in)}}", + "clip mode=individual", + "unbounded coords=jump", + "every axis plot/.append style={line join=round}", + ] + if xlim[0] > xlim[1]: + options.append("x dir=reverse") + if ylim[0] > ylim[1]: + options.append("y dir=reverse") + if not ax.axison: + options.append("hide axis") + else: + options.extend(self._frame()) + for name in ("x", "y"): + options.extend(self._ticks(name)) + options.extend(self._labels()) + options.extend(self._background()) - def _text(self, text): - if not text.get_text().strip(): - return lambda: None - if isinstance(text, Annotation): - text.update_positions(self.figure._get_renderer()) - arrow = ( - getattr(text, "arrow_patch", None) if isinstance(text, Annotation) else None + self.axis = Axis2D( + xlabel=latex(ax.get_xlabel()) if ax.xaxis.get_visible() else "", + ylabel=latex(ax.get_ylabel()) if ax.yaxis.get_visible() else "", + title=latex(self._title()), + xlim=(float(min(xlim)), float(max(xlim))), + ylim=(float(min(ylim)), float(max(ylim))), + xlog=ax.get_xscale() == "log", + ylog=ax.get_yscale() == "log", + grid=None, + width=f"{self.size[0]:.4f}in", + height=f"{self.size[1]:.4f}in", + options=options, ) - arrow_emit = None - if arrow is not None and arrow.get_visible(): - arrow_emit = self._patch(arrow, clip=False) - if arrow_emit is None: - return None - display = _text_display_position(text) - if not self.figure.bbox.contains(*display): - return arrow_emit or (lambda: None) - fx, fy = self.ax.transAxes.inverted().transform(display) - node = _text_node(text, self.colors, f"(axis description cs:{fx:.5g},{fy:.5g})") - - def emit(): - if arrow_emit is not None: - arrow_emit() - self.axis.add_raw(node) - - return emit - - -# artists drawn as images without a warning: images, meshes, filled contours, -# quivers and the like have no better form in pgfplots -_ALWAYS_RASTER = (AxesImage, Collection) - - -def _marker_name(path) -> str: - for marker in _MARKS: - style = MarkerStyle(marker) - candidate = style.get_path().transformed(style.get_transform()) - if candidate.vertices.shape == path.vertices.shape and np.allclose( - candidate.vertices, path.vertices - ): - return marker - return "o" - - -def _with_gaps(path): - """The vertices of a path as one array, its separate pieces joined by nan.""" - out = [] - for vertex, code in zip(path.vertices, path.codes): - if code == Path.STOP: - break - if code == Path.MOVETO and out: - out.append((np.nan, np.nan)) - out.append(tuple(vertex)) - return np.asarray(out, dtype=float).reshape(-1, 2) + self._legend_setup() + self._contents() + self._legend_entries() + if self.has_images: + self.axis.options.append("axis on top") + grid = self._grid() + if grid: + self.axis.options.extend(grid) + self.parent.tikz.axes.append(self.axis) diff --git a/src/maxplotlib/canvas/canvas.py b/src/maxplotlib/canvas/canvas.py index fb08d64..50769c1 100644 --- a/src/maxplotlib/canvas/canvas.py +++ b/src/maxplotlib/canvas/canvas.py @@ -41,6 +41,17 @@ def _parse_bool_env_var(name: str, default: bool = False) -> bool: return value.strip().lower() in {"1", "true", "yes", "on"} +def _running_in_jupyter() -> bool: + """Return whether the current process is running in a Jupyter kernel.""" + try: + from IPython import get_ipython + except ImportError: + return False + + shell = get_ipython() + return shell is not None and "IPKernelApp" in getattr(shell, "config", {}) + + def _display_matplotlib_figure_in_notebook(fig) -> bool: """Display a Matplotlib figure through IPython when running in Jupyter.""" try: @@ -55,17 +66,6 @@ def _display_matplotlib_figure_in_notebook(fig) -> bool: return True -def _running_in_jupyter() -> bool: - """Return whether the current process is running in a Jupyter kernel.""" - try: - from IPython import get_ipython - except ImportError: - return False - - shell = get_ipython() - return shell is not None and "IPKernelApp" in getattr(shell, "config", {}) - - _MATPLOTLIB_LOC_TO_PLOTLY_ANCHOR = { "upper right": dict(x=1.0, y=1.0, xanchor="right", yanchor="top"), "upper left": dict(x=0.0, y=1.0, xanchor="left", yanchor="top"), @@ -260,7 +260,6 @@ def plot_matplotlib(tikzfigure: TikzFigure, ax, layers=None): node.x, node.y, node.content, - fontsize=self._fontsize, ha="center", va="center", wrap=True, @@ -291,6 +290,15 @@ class Canvas: by drawing order). See :class:`~maxplotlib.subfigure.line_plot.LinePlot`. """ + _FACET_KINDS = { + "plot": "plot", + "scatter": "scatter", + "pcolormesh": "pcolormesh", + "imshow": "add_imshow", + "contour": "contour", + "contourf": "contourf", + } + def __init__( self, nrows: int = 1, @@ -485,15 +493,6 @@ def subplots( return canvas, [row[0] for row in axes] return canvas, axes - _FACET_KINDS = { - "plot": "plot", - "scatter": "scatter", - "pcolormesh": "pcolormesh", - "imshow": "add_imshow", - "contour": "contour", - "contourf": "contourf", - } - @classmethod def facet( cls, @@ -1400,9 +1399,6 @@ def set_legend( def legend(self, row=None, col=None, **kwargs): self._get_or_create_subplot(row, col).set_legend(**kwargs) - def legend(self, row=None, col=None, **kwargs): - self._get_or_create_subplot(row, col).set_legend(**kwargs) - def tick_params(self, row: int | None = None, col: int | None = None, **kwargs): """Configure major/minor tick appearance for a subplot.""" self._get_or_create_subplot(row, col).tick_params(**kwargs) @@ -2064,6 +2060,85 @@ def add_subplot( self._subplots[label] = line_plot return line_plot + def _render( + self, + backend: Backends = "matplotlib", + savefig: bool = False, + layers: list | None = None, + usetex: bool | None = None, + verbose: bool = False, + matplotlib_postprocess=None, + matplotlib_customizations=None, + allow_unsupported: bool = False, + ): + """Render the canvas. + + ``matplotlib_customizations`` accepts either a callable receiving + ``(figure, axes)`` or a mapping whose ``figure`` methods run once and + whose ``axes`` methods run on every axes. Values are keyword arguments, + or use ``{"args": [...], "kwargs": {...}}`` for positional and keyword + arguments. A scalar value is passed as one positional argument. + + ``matplotlib_postprocess`` is an optional callable receiving + ``(figure, axes)`` after a Matplotlib figure has been created. It can + call any Matplotlib API, including APIs not wrapped by maxplotlib. + Both options are only valid with the Matplotlib backend. + """ + resolved_usetex = self._usetex if usetex is None else usetex + + if verbose: + print(f"Plotting figure using backend: {backend}") + + if backend == "matplotlib": + return self.plot_matplotlib( + savefig=savefig, + layers=layers, + usetex=resolved_usetex, + verbose=verbose, + matplotlib_postprocess=matplotlib_postprocess, + matplotlib_customizations=matplotlib_customizations, + ) + elif ( + matplotlib_postprocess is not None or matplotlib_customizations is not None + ): + raise ValueError( + "Matplotlib customizations are only supported with the matplotlib backend" + ) + elif backend == "plotly": + return self.plot_plotly( + savefig=savefig, + layers=layers, + usetex=resolved_usetex, + verbose=verbose, + allow_unsupported=allow_unsupported, + ) + elif backend == "plotext": + return self.plot_plotext( + savefig=savefig, + layers=layers, + verbose=verbose, + ) + elif backend == "tikzfigure": + return self.plot_tikzfigure(savefig=savefig, layers=layers, verbose=verbose) + else: + raise ValueError(f"Invalid backend: {backend}") + + def _save_plotly( + self, fig, filename: str, include_plotlyjs: bool | str = True + ) -> None: + _, extension = os.path.splitext(filename) + extension = extension.lower() + if extension in {".html", ".htm"}: + fig.write_html(filename, include_plotlyjs=include_plotlyjs) + 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 + def savefig( self, filename, @@ -2185,69 +2260,6 @@ def savefig( if verbose: print(f"Saved {filename}") - def _render( - self, - backend: Backends = "matplotlib", - savefig: bool = False, - layers: list | None = None, - usetex: bool | None = None, - verbose: bool = False, - matplotlib_postprocess=None, - matplotlib_customizations=None, - allow_unsupported: bool = False, - ): - """Render the canvas. - - ``matplotlib_customizations`` accepts either a callable receiving - ``(figure, axes)`` or a mapping whose ``figure`` methods run once and - whose ``axes`` methods run on every axes. Values are keyword arguments, - or use ``{"args": [...], "kwargs": {...}}`` for positional and keyword - arguments. A scalar value is passed as one positional argument. - - ``matplotlib_postprocess`` is an optional callable receiving - ``(figure, axes)`` after a Matplotlib figure has been created. It can - call any Matplotlib API, including APIs not wrapped by maxplotlib. - Both options are only valid with the Matplotlib backend. - """ - resolved_usetex = self._usetex if usetex is None else usetex - - if verbose: - print(f"Plotting figure using backend: {backend}") - - if backend == "matplotlib": - return self.plot_matplotlib( - savefig=savefig, - layers=layers, - usetex=resolved_usetex, - verbose=verbose, - matplotlib_postprocess=matplotlib_postprocess, - matplotlib_customizations=matplotlib_customizations, - ) - elif ( - matplotlib_postprocess is not None or matplotlib_customizations is not None - ): - raise ValueError( - "Matplotlib customizations are only supported with the matplotlib backend" - ) - elif backend == "plotly": - return self.plot_plotly( - savefig=savefig, - layers=layers, - usetex=resolved_usetex, - verbose=verbose, - allow_unsupported=allow_unsupported, - ) - elif backend == "plotext": - return self.plot_plotext( - savefig=savefig, - layers=layers, - verbose=verbose, - ) - elif backend == "tikzfigure": - return self.plot_tikzfigure(savefig=savefig, layers=layers, verbose=verbose) - else: - raise ValueError(f"Invalid backend: {backend}") - def plot(self, *args, backend=None, **kwargs): """Add a line, or render when called with backend options. @@ -3159,22 +3171,6 @@ def plot_plotly( self._plotly_fig = fig return fig - def _save_plotly( - self, fig, filename: str, include_plotlyjs: bool | str = True - ) -> None: - _, extension = os.path.splitext(filename) - extension = extension.lower() - if extension in {".html", ".htm"}: - fig.write_html(filename, include_plotlyjs=include_plotlyjs) - 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 - def to_html( self, layers: list | None = None, diff --git a/src/maxplotlib/subfigure/line_plot.py b/src/maxplotlib/subfigure/line_plot.py index da86d4f..b5bc79c 100644 --- a/src/maxplotlib/subfigure/line_plot.py +++ b/src/maxplotlib/subfigure/line_plot.py @@ -323,19 +323,6 @@ def add_line( } self._add(ld, layer) - def plot(self, x, y=None, layer=0, **kwargs): - """Matplotlib-style alias for :meth:`add_line`. - - ``plot(da)`` with a 1-D ``xarray.DataArray`` plots it against its - coordinate; see :meth:`_plot_dataarray`. - """ - if y is None: - if not xarray_support.is_dataarray(x): - raise TypeError("plot(x, y) requires both x and y data") - self._plot_dataarray(self.add_line, x, layer, kwargs) - return - self.add_line(x, y, layer=layer, **kwargs) - def _set_default_labels(self, xlabel, ylabel, da=None): """Set axis labels, and a title from ``da``, that the user has not set.""" if not self._xlabel and xlabel: @@ -381,6 +368,28 @@ def _plot_dataarray(self, method, da, layer, kwargs): if hue is not None and add_legend: self._legend = True + def plot(self, x, y=None, layer=0, **kwargs): + """Matplotlib-style alias for :meth:`add_line`. + + ``plot(da)`` with a 1-D ``xarray.DataArray`` plots it against its + coordinate; see :meth:`_plot_dataarray`. + """ + if y is None: + if not xarray_support.is_dataarray(x): + raise TypeError("plot(x, y) requires both x and y data") + self._plot_dataarray(self.add_line, x, layer, kwargs) + return + self.add_line(x, y, layer=layer, **kwargs) + + @staticmethod + def _mesh_of(da, kwargs, method): + return xarray_support.mesh_data( + da, + x=kwargs.pop("xcoord", None), + y=kwargs.pop("ycoord", None), + method=method, + ) + def _mesh_dataarray(self, method, da, layer, kwargs, add_colorbar=True, name=None): """Draw a 2-D DataArray with ``method(x, y, z, ...)``. @@ -398,15 +407,6 @@ def _mesh_dataarray(self, method, da, layer, kwargs, add_colorbar=True, name=Non if add_colorbar: self.add_colorbar(label=mesh.zlabel, layer=layer) - @staticmethod - def _mesh_of(da, kwargs, method): - return xarray_support.mesh_data( - da, - x=kwargs.pop("xcoord", None), - y=kwargs.pop("ycoord", None), - method=method, - ) - def scatter(self, x, y=None, layer=0, **kwargs): """ Add a scatter plot to the subplot. @@ -1439,6 +1439,9 @@ def text(self, x, y, s, layer=0, **kwargs): } self._add(ld, layer) + def _imshow_xyz(self, x, y, z, layer=0, **kwargs): + self.add_imshow(z, layer=layer, **kwargs) + def add_imshow(self, data, layer=0, **kwargs): """Add an image. @@ -1467,9 +1470,6 @@ def add_imshow(self, data, layer=0, **kwargs): } self._add(ld, layer) - def _imshow_xyz(self, x, y, z, layer=0, **kwargs): - self.add_imshow(z, layer=layer, **kwargs) - def imshow(self, data, layer=0, **kwargs): """Matplotlib-style alias for :meth:`add_imshow`.""" self.add_imshow(data, layer=layer, **kwargs) @@ -1503,6 +1503,35 @@ def layers(self): layers.append(layer_name) return layers + @staticmethod + def _tag_matplotlib_artists(ax, artists_before, meta): + """Set ``gid`` on every artist added since ``artists_before``.""" + if artists_before is None: + return + for artist in ax.get_children(): + if id(artist) in artists_before: + continue + try: + artist.set_gid(str(meta)) + except AttributeError: + continue + + def _shift_x(self, values): + """Apply ``xshift``/``xscale``; values pass through unchanged by default. + + Skipping the no-op arithmetic keeps non-numeric data, such as + datetimes or category labels, plottable. + """ + if self._xshift == 0 and self._xscale == 1: + return values + return (values + self._xshift) * self._xscale + + def _shift_y(self, values): + """Apply ``yshift``/``yscale``; see :meth:`_shift_x`.""" + if self._yshift == 0 and self._yscale == 1: + return values + return (values + self._yshift) * self._yscale + def plot_matplotlib( self, ax, @@ -2031,18 +2060,133 @@ def plot_matplotlib( if index in indicators: ax.indicate_inset_zoom(inset, **indicators[index]) - @staticmethod - def _tag_matplotlib_artists(ax, artists_before, meta): - """Set ``gid`` on every artist added since ``artists_before``.""" - if artists_before is None: + def _symlog_transform(self, values): + array = np.asarray(values, dtype=float) + return np.sign(array) * np.log10(1.0 + np.abs(array)) + + 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 = self._shift_x(array) + else: + transformed = self._shift_y(array) + 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 _plotly_shape_corners(self, shape): + """Return the polygon outline of a rectangular shape, if hoverable. + + Shapes anchored to the paper (full-height spans from ``axvspan``, for + instance) have no data coordinates on that axis; they can only be + given a hover overlay when the subplot has explicit limits to + substitute. + """ + if shape.get("type") != "rect": + return None + try: + if shape.get("xref") == "paper": + if self._xmin is None or self._xmax is None: + return None + x0 = self._transform_scalar_x(self._xmin) + x1 = self._transform_scalar_x(self._xmax) + else: + x0 = float(shape["x0"]) + x1 = float(shape["x1"]) + if shape.get("yref") == "paper": + if self._ymin is None or self._ymax is None: + return None + y0 = self._transform_scalar_y(self._ymin) + y1 = self._transform_scalar_y(self._ymax) + else: + y0 = float(shape["y0"]) + y1 = float(shape["y1"]) + except (KeyError, TypeError, ValueError): + return None + return [x0, x1, x1, x0], [y0, y0, y1, y1] + + def _apply_plotly_neutral( + self, + line, + traces, + shapes, + trace_start, + shape_start, + skip_shapes=(), + ): + """Apply the backend-neutral ``hover=`` / ``meta=`` options. + + Every trace and shape produced by ``line`` is tagged, so callers never + have to match traces by creation order after the figure is built. + ``hover`` may be a single string, one entry per point, or a 2-D array + for heatmap-like traces; shapes, which cannot show hover text of their + own, get an invisible overlay trace instead. + """ + hover = line.get("hover") + meta = line.get("meta") + if hover is None and meta is None: return - for artist in ax.get_children(): - if id(artist) in artists_before: + + for trace in traces[trace_start:]: + if meta is not None: + try: + trace.update(meta=meta) + except (ValueError, TypeError): + pass + if hover is not None: + try: + trace.update(hovertext=hover, hoverinfo="text") + except (ValueError, TypeError): + pass + + for index in range(shape_start, len(shapes)): + shape = shapes[index] + if meta is not None and "name" not in shape: + shape["name"] = str(meta) + if hover is None or index in skip_shapes: continue - try: - artist.set_gid(str(meta)) - except AttributeError: + corners = self._plotly_shape_corners(shape) + if corners is None: + continue + x_points, y_points = corners + traces.append( + go.Scatter( + x=list(x_points) + [x_points[0]], + y=list(y_points) + [y_points[0]], + mode="lines", + fill="toself", + fillcolor="rgba(0,0,0,0)", + line=dict(width=0), + hoveron="fills", + hoverinfo="text", + hovertext=hover, + meta=meta, + showlegend=False, + ) + ) + + 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 plot_plotly(self, layers=None, allow_unsupported=False): if hasattr(self, "_import_projection"): @@ -3660,154 +3804,10 @@ def format_bar_label(value): return traces, shapes, annotations - def _apply_plotly_neutral( - self, - line, - traces, - shapes, - trace_start, - shape_start, - skip_shapes=(), - ): - """Apply the backend-neutral ``hover=`` / ``meta=`` options. - - Every trace and shape produced by ``line`` is tagged, so callers never - have to match traces by creation order after the figure is built. - ``hover`` may be a single string, one entry per point, or a 2-D array - for heatmap-like traces; shapes, which cannot show hover text of their - own, get an invisible overlay trace instead. - """ - hover = line.get("hover") - meta = line.get("meta") - if hover is None and meta is None: - return - - for trace in traces[trace_start:]: - if meta is not None: - try: - trace.update(meta=meta) - except (ValueError, TypeError): - pass - if hover is not None: - try: - trace.update(hovertext=hover, hoverinfo="text") - except (ValueError, TypeError): - pass - - for index in range(shape_start, len(shapes)): - shape = shapes[index] - if meta is not None and "name" not in shape: - shape["name"] = str(meta) - if hover is None or index in skip_shapes: - continue - corners = self._plotly_shape_corners(shape) - if corners is None: - continue - x_points, y_points = corners - traces.append( - go.Scatter( - x=list(x_points) + [x_points[0]], - y=list(y_points) + [y_points[0]], - mode="lines", - fill="toself", - fillcolor="rgba(0,0,0,0)", - line=dict(width=0), - hoveron="fills", - hoverinfo="text", - hovertext=hover, - meta=meta, - showlegend=False, - ) - ) - - def _plotly_shape_corners(self, shape): - """Return the polygon outline of a rectangular shape, if hoverable. - - Shapes anchored to the paper (full-height spans from ``axvspan``, for - instance) have no data coordinates on that axis; they can only be - given a hover overlay when the subplot has explicit limits to - substitute. - """ - if shape.get("type") != "rect": - return None - try: - if shape.get("xref") == "paper": - if self._xmin is None or self._xmax is None: - return None - x0 = self._transform_scalar_x(self._xmin) - x1 = self._transform_scalar_x(self._xmax) - else: - x0 = float(shape["x0"]) - x1 = float(shape["x1"]) - if shape.get("yref") == "paper": - if self._ymin is None or self._ymax is None: - return None - y0 = self._transform_scalar_y(self._ymin) - y1 = self._transform_scalar_y(self._ymax) - else: - y0 = float(shape["y0"]) - y1 = float(shape["y1"]) - except (KeyError, TypeError, ValueError): - return None - return [x0, x1, x1, x0], [y0, y0, y1, y1] - - 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 _shift_x(self, values): - """Apply ``xshift``/``xscale``; values pass through unchanged by default. - - Skipping the no-op arithmetic keeps non-numeric data, such as - datetimes or category labels, plottable. - """ - if self._xshift == 0 and self._xscale == 1: - return values - return (values + self._xshift) * self._xscale - - def _shift_y(self, values): - """Apply ``yshift``/``yscale``; see :meth:`_shift_x`.""" - if self._yshift == 0 and self._yscale == 1: - return values - return (values + self._yshift) * self._yscale - - def _plotext_axis_transform(self, values, axis: str): - array = np.asarray(values) - if axis == "x": - transformed = self._shift_x(array) - else: - transformed = self._shift_y(array) - 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] @@ -3930,6 +3930,23 @@ def _coerce_numeric_array(self, values): except (TypeError, ValueError): return None + def _plotext_error_values(self, error, count): + if error is None: + return None + values = np.asarray(error, dtype=float) + if values.ndim == 0: + # Plotext's error() interprets each value as the full bar width, + # while Matplotlib interprets it as the distance from the point + # to one end of a symmetric error bar. + return [float(values) * 2] * count + if values.ndim == 2 and values.shape[0] == 2: + # Matplotlib's asymmetric form is [lower, upper]. Plotext only + # accepts symmetric widths, so preserve the total extent. + values = values[0] + values[1] + else: + values = values * 2 + return values.tolist() + def _plotext_bounds(self, layers=None): xs = [] ys = [] @@ -4011,23 +4028,6 @@ def extend_y(values): extend_y(self._transform_y(patch_ys)) return xs, ys - def _plotext_error_values(self, error, count): - if error is None: - return None - values = np.asarray(error, dtype=float) - if values.ndim == 0: - # Plotext's error() interprets each value as the full bar width, - # while Matplotlib interprets it as the distance from the point - # to one end of a symmetric error bar. - return [float(values) * 2] * count - if values.ndim == 2 and values.shape[0] == 2: - # Matplotlib's asymmetric form is [lower, upper]. Plotext only - # accepts symmetric widths, so preserve the total extent. - values = values[0] + values[1] - else: - values = values * 2 - return values.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) diff --git a/src/maxplotlib/tests/test_gantt_chart.py b/src/maxplotlib/tests/test_gantt_chart.py index 496e77e..c94057f 100644 --- a/src/maxplotlib/tests/test_gantt_chart.py +++ b/src/maxplotlib/tests/test_gantt_chart.py @@ -169,7 +169,7 @@ def test_gantt_chart_many_tasks(): n_tasks = 20 canvas = Canvas(nrows=1, ncols=1) canvas.gantt( - tasks=[f"Task {i+1}" for i in range(n_tasks)], + tasks=[f"Task {i + 1}" for i in range(n_tasks)], start_times=list(range(0, n_tasks * 2, 2)), durations=[2] * n_tasks, ) diff --git a/src/maxplotlib/utils/xarray_support.py b/src/maxplotlib/utils/xarray_support.py index a36438d..95f9def 100644 --- a/src/maxplotlib/utils/xarray_support.py +++ b/src/maxplotlib/utils/xarray_support.py @@ -220,6 +220,27 @@ def curvilinear(self) -> bool: return np.ndim(self.x) == 2 +def _curvilinear_mesh(da, x, y): + def grid(name): + dims, coord = _resolve(da, name) + if coord is None: + coord = type(da)(np.arange(da.sizes[name]), dims=dims) + return magnitude(coord.broadcast_like(da).transpose(*da.dims)), coord + + xvalues, xcoord = grid(x) + yvalues, ycoord = grid(y) + return MeshData( + xvalues, + yvalues, + magnitude(da), + _label(xcoord) if xcoord.name is not None else str(x), + _label(ycoord) if ycoord.name is not None else str(y), + value_label(da), + x, + y, + ) + + def mesh_data(da, x=None, y=None, method="pcolormesh"): """Return the coordinates, values and labels for a 2-D DataArray. @@ -259,27 +280,6 @@ def mesh_data(da, x=None, y=None, method="pcolormesh"): return MeshData(xvalues, yvalues, z, xlabel, ylabel, value_label(da), x, y) -def _curvilinear_mesh(da, x, y): - def grid(name): - dims, coord = _resolve(da, name) - if coord is None: - coord = type(da)(np.arange(da.sizes[name]), dims=dims) - return magnitude(coord.broadcast_like(da).transpose(*da.dims)), coord - - xvalues, xcoord = grid(x) - yvalues, ycoord = grid(y) - return MeshData( - xvalues, - yvalues, - magnitude(da), - _label(xcoord) if xcoord.name is not None else str(x), - _label(ycoord) if ycoord.name is not None else str(y), - value_label(da), - x, - y, - ) - - def _edges(values, name): """Outer edges of evenly spaced cell centres.""" if values.size == 1: diff --git a/src/maxplotlib/xarray.py b/src/maxplotlib/xarray.py index e95f4a0..c5d95b7 100644 --- a/src/maxplotlib/xarray.py +++ b/src/maxplotlib/xarray.py @@ -38,23 +38,21 @@ class MaxplotAccessor: def __init__(self, da): self._da = da - def __call__(self, **kwargs): - """Plot with a kind chosen from the dimensions, like ``da.plot()``. - - After removing any ``col``/``row`` dimensions, 1-D data (or 2-D data - with ``hue=``) is drawn with :meth:`line` and 2-D data with - :meth:`pcolormesh`. - """ - facet_dims = {kwargs.get("col"), kwargs.get("row")} - {None} - ndim = self._da.ndim - len(facet_dims & set(self._da.dims)) - if ndim == 1 or (ndim == 2 and "hue" in kwargs): - return self.line(**kwargs) - if ndim == 2: - return self.pcolormesh(**kwargs) - raise ValueError( - f"da.maxplot() plots 1-D or 2-D data, got dims {self._da.dims}. " - "Select a slice first, e.g. da.sel(...), or facet with col=/row=." - ) + def _draw(self, kind, kwargs): + canvas_kwargs = kwargs.pop("canvas_kwargs", None) + # xarray's x=/y= are xcoord=/ycoord= on Canvas methods, where x and y + # are the positional data arguments. + for axis in ("x", "y"): + if axis in kwargs: + kwargs[f"{axis}coord"] = kwargs.pop(axis) + if any(name in kwargs for name in _FACET_KWARGS): + canvas, _ = Canvas.facet( + self._da, kind=kind, canvas_kwargs=canvas_kwargs, **kwargs + ) + return canvas + canvas = Canvas(**(canvas_kwargs or {})) + getattr(canvas, kind)(self._da, **kwargs) + return canvas def line(self, **kwargs): """Lines against the coordinate; ``hue=`` for one per value.""" @@ -80,21 +78,23 @@ def contourf(self, **kwargs): """Filled contours of 2-D data, with a labelled colorbar.""" return self._draw("contourf", kwargs) - def _draw(self, kind, kwargs): - canvas_kwargs = kwargs.pop("canvas_kwargs", None) - # xarray's x=/y= are xcoord=/ycoord= on Canvas methods, where x and y - # are the positional data arguments. - for axis in ("x", "y"): - if axis in kwargs: - kwargs[f"{axis}coord"] = kwargs.pop(axis) - if any(name in kwargs for name in _FACET_KWARGS): - canvas, _ = Canvas.facet( - self._da, kind=kind, canvas_kwargs=canvas_kwargs, **kwargs - ) - return canvas - canvas = Canvas(**(canvas_kwargs or {})) - getattr(canvas, kind)(self._da, **kwargs) - return canvas + def __call__(self, **kwargs): + """Plot with a kind chosen from the dimensions, like ``da.plot()``. + + After removing any ``col``/``row`` dimensions, 1-D data (or 2-D data + with ``hue=``) is drawn with :meth:`line` and 2-D data with + :meth:`pcolormesh`. + """ + facet_dims = {kwargs.get("col"), kwargs.get("row")} - {None} + ndim = self._da.ndim - len(facet_dims & set(self._da.dims)) + if ndim == 1 or (ndim == 2 and "hue" in kwargs): + return self.line(**kwargs) + if ndim == 2: + return self.pcolormesh(**kwargs) + raise ValueError( + f"da.maxplot() plots 1-D or 2-D data, got dims {self._da.dims}. " + "Select a slice first, e.g. da.sel(...), or facet with col=/row=." + ) @xr.register_dataset_accessor("maxplot") @@ -104,27 +104,6 @@ class MaxplotDatasetAccessor: def __init__(self, ds): self._ds = ds - def line(self, x, y, hue=None, **kwargs): - """Lines of variable ``y`` against variable or coordinate ``x``. - - ``hue`` names a dimension to draw one line per value of. Other - arguments are as for ``DataArray.maxplot.line``, including facets. - """ - return self._as_dataarray(x, y).maxplot.line(x=x, hue=hue, **kwargs) - - def scatter(self, x, y, hue=None, **kwargs): - """Points of variable ``y`` against variable or coordinate ``x``. - - ``hue`` names a dimension, for one series per value, or a variable, - which colors the points by value with a labelled colorbar (turned off - by ``add_colorbar=False``; ``cmap``, ``robust`` and ``center`` apply). - """ - if hue is None or hue in self._ds.dims: - return self._as_dataarray(x, y).maxplot.scatter(x=x, hue=hue, **kwargs) - if any(name in kwargs for name in _FACET_KWARGS): - raise ValueError("facets are not supported with a variable as hue=") - return self._scatter_colored(x, y, hue, kwargs) - def _variable(self, name): if name not in self._ds.variables: raise ValueError( @@ -145,6 +124,14 @@ def _as_dataarray(self, x, y): ) return yvar.assign_coords({x: xvar}) + def line(self, x, y, hue=None, **kwargs): + """Lines of variable ``y`` against variable or coordinate ``x``. + + ``hue`` names a dimension to draw one line per value of. Other + arguments are as for ``DataArray.maxplot.line``, including facets. + """ + return self._as_dataarray(x, y).maxplot.line(x=x, hue=hue, **kwargs) + def _scatter_colored(self, x, y, hue, kwargs): canvas_kwargs = kwargs.pop("canvas_kwargs", None) add_colorbar = kwargs.pop("add_colorbar", True) @@ -168,3 +155,16 @@ def _scatter_colored(self, x, y, hue, kwargs): if add_colorbar: canvas.colorbar(label=xarray_support.value_label(cvar)) return canvas + + def scatter(self, x, y, hue=None, **kwargs): + """Points of variable ``y`` against variable or coordinate ``x``. + + ``hue`` names a dimension, for one series per value, or a variable, + which colors the points by value with a labelled colorbar (turned off + by ``add_colorbar=False``; ``cmap``, ``robust`` and ``center`` apply). + """ + if hue is None or hue in self._ds.dims: + return self._as_dataarray(x, y).maxplot.scatter(x=x, hue=hue, **kwargs) + if any(name in kwargs for name in _FACET_KWARGS): + raise ValueError("facets are not supported with a variable as hue=") + return self._scatter_colored(x, y, hue, kwargs) diff --git a/tutorials/tutorial_01.ipynb b/tutorials/tutorial_01.ipynb index 21430b5..79774a6 100644 --- a/tutorials/tutorial_01.ipynb +++ b/tutorials/tutorial_01.ipynb @@ -25,9 +25,10 @@ "metadata": {}, "outputs": [], "source": [ - "from maxplotlib import Canvas\n", "import numpy as np\n", "\n", + "from maxplotlib import Canvas\n", + "\n", "%matplotlib inline" ] }, diff --git a/tutorials/tutorial_02.ipynb b/tutorials/tutorial_02.ipynb index 651e27e..bffb8f7 100644 --- a/tutorials/tutorial_02.ipynb +++ b/tutorials/tutorial_02.ipynb @@ -24,9 +24,10 @@ "metadata": {}, "outputs": [], "source": [ - "from maxplotlib import Canvas\n", - "from tikzfigure import TikzFigure\n", "import numpy as np\n", + "from tikzfigure import TikzFigure\n", + "\n", + "from maxplotlib import Canvas\n", "\n", "%matplotlib inline\n", "%load_ext autoreload\n", diff --git a/tutorials/tutorial_03.ipynb b/tutorials/tutorial_03.ipynb index f6b4c18..6720b1a 100644 --- a/tutorials/tutorial_03.ipynb +++ b/tutorials/tutorial_03.ipynb @@ -23,9 +23,10 @@ "metadata": {}, "outputs": [], "source": [ - "from maxplotlib import Canvas\n", "import numpy as np\n", "\n", + "from maxplotlib import Canvas\n", + "\n", "%matplotlib inline\n", "\n", "rng = np.random.default_rng(42)\n", diff --git a/tutorials/tutorial_04.ipynb b/tutorials/tutorial_04.ipynb index 81209c1..8964180 100644 --- a/tutorials/tutorial_04.ipynb +++ b/tutorials/tutorial_04.ipynb @@ -17,8 +17,9 @@ "metadata": {}, "outputs": [], "source": [ - "from maxplotlib import Canvas\n", - "import numpy as np" + "import numpy as np\n", + "\n", + "from maxplotlib import Canvas" ] }, { diff --git a/tutorials/tutorial_05.ipynb b/tutorials/tutorial_05.ipynb index b7c53bc..86b58ec 100644 --- a/tutorials/tutorial_05.ipynb +++ b/tutorials/tutorial_05.ipynb @@ -17,8 +17,9 @@ "metadata": {}, "outputs": [], "source": [ - "from maxplotlib import Canvas\n", - "import numpy as np" + "import numpy as np\n", + "\n", + "from maxplotlib import Canvas" ] }, { diff --git a/tutorials/tutorial_06.ipynb b/tutorials/tutorial_06.ipynb index 51dc24f..332f65e 100644 --- a/tutorials/tutorial_06.ipynb +++ b/tutorials/tutorial_06.ipynb @@ -17,8 +17,9 @@ "metadata": {}, "outputs": [], "source": [ - "from maxplotlib import Canvas\n", - "import numpy as np" + "import numpy as np\n", + "\n", + "from maxplotlib import Canvas" ] }, { diff --git a/tutorials/tutorial_07_tikz.ipynb b/tutorials/tutorial_07_tikz.ipynb index 95f2db0..130de00 100644 --- a/tutorials/tutorial_07_tikz.ipynb +++ b/tutorials/tutorial_07_tikz.ipynb @@ -36,6 +36,7 @@ "source": [ "import numpy as np\n", "import tikzfigure as tz\n", + "\n", "from maxplotlib import Canvas" ] }, @@ -686,7 +687,7 @@ "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", + " (float(xi), float(lo)) for xi, lo in zip(reversed(x_half), reversed(lower))\n", "]\n", "tf_final.draw(region, fill=\"cblue!20\", draw=\"none\", cycle=True)\n", "\n", @@ -816,6 +817,7 @@ "outputs": [], "source": [ "import numpy as np\n", + "\n", "from maxplotlib import Canvas\n", "\n", "x = np.arange(5)\n", diff --git a/tutorials/tutorial_08_plotly.ipynb b/tutorials/tutorial_08_plotly.ipynb index 7feb299..913a4dc 100644 --- a/tutorials/tutorial_08_plotly.ipynb +++ b/tutorials/tutorial_08_plotly.ipynb @@ -34,8 +34,9 @@ "%autoreload 2\n", "%matplotlib inline\n", "\n", - "from maxplotlib import Canvas\n", - "import numpy as np" + "import numpy as np\n", + "\n", + "from maxplotlib import Canvas" ] }, { diff --git a/tutorials/tutorial_10_matplotlib_nxm_spacing.ipynb b/tutorials/tutorial_10_matplotlib_nxm_spacing.ipynb index 7044b62..877f500 100644 --- a/tutorials/tutorial_10_matplotlib_nxm_spacing.ipynb +++ b/tutorials/tutorial_10_matplotlib_nxm_spacing.ipynb @@ -23,8 +23,8 @@ "metadata": {}, "outputs": [], "source": [ - "import numpy as np\n", "import matplotlib.pyplot as plt\n", + "import numpy as np\n", "\n", "from maxplotlib import Canvas" ] diff --git a/tutorials/tutorial_13_advanced_matplotlib.ipynb b/tutorials/tutorial_13_advanced_matplotlib.ipynb index 47e2954..b917a55 100644 --- a/tutorials/tutorial_13_advanced_matplotlib.ipynb +++ b/tutorials/tutorial_13_advanced_matplotlib.ipynb @@ -22,6 +22,7 @@ "outputs": [], "source": [ "import numpy as np\n", + "\n", "from maxplotlib import Canvas\n", "\n", "x = np.linspace(0, 2 * np.pi, 200)" diff --git a/tutorials/tutorial_14_axis_and_layout_controls.ipynb b/tutorials/tutorial_14_axis_and_layout_controls.ipynb index 08ee483..82b5398 100644 --- a/tutorials/tutorial_14_axis_and_layout_controls.ipynb +++ b/tutorials/tutorial_14_axis_and_layout_controls.ipynb @@ -18,6 +18,7 @@ "outputs": [], "source": [ "import numpy as np\n", + "\n", "from maxplotlib import Canvas\n", "\n", "x = np.linspace(0.1, 10, 200)" diff --git a/tutorials/tutorial_15_tikzfigure_subplots.ipynb b/tutorials/tutorial_15_tikzfigure_subplots.ipynb index c206c8d..a5e974b 100644 --- a/tutorials/tutorial_15_tikzfigure_subplots.ipynb +++ b/tutorials/tutorial_15_tikzfigure_subplots.ipynb @@ -21,9 +21,10 @@ "metadata": {}, "outputs": [], "source": [ - "from maxplotlib import Canvas\n", "import numpy as np\n", "\n", + "from maxplotlib import Canvas\n", + "\n", "%load_ext autoreload\n", "%autoreload 2" ] diff --git a/tutorials/tutorial_17_xarray.ipynb b/tutorials/tutorial_17_xarray.ipynb index b2d37c0..5e18e96 100644 --- a/tutorials/tutorial_17_xarray.ipynb +++ b/tutorials/tutorial_17_xarray.ipynb @@ -34,7 +34,7 @@ "import numpy as np\n", "import xarray as xr\n", "\n", - "import maxplotlib.xarray # registers da.maxplot and ds.maxplot\n", + "import maxplotlib.xarray # noqa: F401 # registers da.maxplot and ds.maxplot\n", "from maxplotlib import Canvas" ] }, From 54b18503935fcafb3db0081d4ac3b7dcca152baa Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 27 Oct 2026 10:49:44 +0100 Subject: [PATCH 2/2] Remove the mypy check --- .github/workflows/static_analysis.yml | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index d2ea9fa..acdd099 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -24,23 +24,6 @@ jobs: ./cloc --version ./cloc $(git ls-files) - 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/ - prospector: runs-on: ubuntu-latest steps: