maxplotlibx 0.1.8__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. {maxplotlibx-0.1.8/src/maxplotlibx.egg-info → maxplotlibx-0.2.0}/PKG-INFO +74 -16
  2. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/README.md +68 -14
  3. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/pyproject.toml +7 -2
  4. maxplotlibx-0.2.0/src/maxplotlib/backends/matplotlib/import_state.py +342 -0
  5. maxplotlibx-0.2.0/src/maxplotlib/backends/matplotlib/importer.py +1078 -0
  6. maxplotlibx-0.2.0/src/maxplotlib/backends/tikzfigure/__init__.py +9 -0
  7. maxplotlibx-0.2.0/src/maxplotlib/backends/tikzfigure/convert.py +1331 -0
  8. maxplotlibx-0.2.0/src/maxplotlib/backends/tikzfigure/text.py +183 -0
  9. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/src/maxplotlib/canvas/canvas.py +695 -346
  10. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/src/maxplotlib/subfigure/line_plot.py +1072 -484
  11. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/src/maxplotlib/tests/test_canvas.py +88 -21
  12. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/src/maxplotlib/tests/test_flame_chart.py +9 -0
  13. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/src/maxplotlib/tests/test_gantt_chart.py +9 -0
  14. maxplotlibx-0.2.0/src/maxplotlib/tests/test_matplotlib_import.py +348 -0
  15. maxplotlibx-0.2.0/src/maxplotlib/tests/test_matplotlib_import_extended.py +612 -0
  16. maxplotlibx-0.2.0/src/maxplotlib/tests/test_plotly_backend.py +684 -0
  17. maxplotlibx-0.2.0/src/maxplotlib/tests/test_tikzfigure_backend.py +259 -0
  18. maxplotlibx-0.2.0/src/maxplotlib/tests/test_xarray.py +896 -0
  19. maxplotlibx-0.2.0/src/maxplotlib/utils/xarray_support.py +346 -0
  20. maxplotlibx-0.2.0/src/maxplotlib/xarray.py +170 -0
  21. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0/src/maxplotlibx.egg-info}/PKG-INFO +74 -16
  22. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/src/maxplotlibx.egg-info/SOURCES.txt +11 -0
  23. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/src/maxplotlibx.egg-info/requires.txt +6 -1
  24. maxplotlibx-0.1.8/src/maxplotlib/tests/test_plotly_backend.py +0 -338
  25. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/LICENSE +0 -0
  26. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/setup.cfg +0 -0
  27. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/src/maxplotlib/__init__.py +0 -0
  28. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/src/maxplotlib/backends/matplotlib/utils.py +0 -0
  29. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/src/maxplotlib/backends/plotext/__init__.py +0 -0
  30. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/src/maxplotlib/backends/plotext/figure.py +0 -0
  31. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/src/maxplotlib/backends/plotly/__init__.py +0 -0
  32. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/src/maxplotlib/backends/plotly/utils.py +0 -0
  33. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/src/maxplotlib/canvas/__init__.py +0 -0
  34. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/src/maxplotlib/colors/__init__.py +0 -0
  35. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/src/maxplotlib/colors/colors.py +0 -0
  36. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/src/maxplotlib/linestyle/__init__.py +0 -0
  37. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/src/maxplotlib/linestyle/linestyle.py +0 -0
  38. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/src/maxplotlib/objects/__init__.py +0 -0
  39. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/src/maxplotlib/objects/node.py +0 -0
  40. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/src/maxplotlib/objects/path.py +0 -0
  41. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/src/maxplotlib/subfigure/__init__.py +0 -0
  42. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/src/maxplotlib/subfigure/subfigure.py +0 -0
  43. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/src/maxplotlib/tests/test_imports.py +0 -0
  44. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/src/maxplotlib/tests/test_plot.py +0 -0
  45. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/src/maxplotlib/tests/test_plotext.py +0 -0
  46. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/src/maxplotlib/tests/test_styles_and_colors.py +0 -0
  47. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/src/maxplotlib/utils/__init__.py +0 -0
  48. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/src/maxplotlib/utils/options.py +0 -0
  49. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/src/maxplotlibx.egg-info/dependency_links.txt +0 -0
  50. {maxplotlibx-0.1.8 → maxplotlibx-0.2.0}/src/maxplotlibx.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maxplotlibx
3
- Version: 0.1.8
3
+ Version: 0.2.0
4
4
  Summary: A reproducible plotting module with various backends and export options.
5
5
  Author: Max
6
6
  Project-URL: Source, https://github.com/max-models/maxplotlib
@@ -13,11 +13,15 @@ Requires-Dist: matplotlib
13
13
  Requires-Dist: pint
14
14
  Requires-Dist: plotly
15
15
  Requires-Dist: plotext<7,>=6.0
16
- Requires-Dist: tikzfigure[vis]>=0.3.0
16
+ Requires-Dist: tikzfigure[vis]>=0.4.0
17
17
  Provides-Extra: test
18
18
  Requires-Dist: pytest; extra == "test"
19
19
  Requires-Dist: coverage; extra == "test"
20
+ Requires-Dist: xarray; extra == "test"
21
+ Provides-Extra: xarray
22
+ Requires-Dist: xarray; extra == "xarray"
20
23
  Provides-Extra: docs
24
+ Requires-Dist: xarray; extra == "docs"
21
25
  Requires-Dist: myst-parser; extra == "docs"
22
26
  Requires-Dist: sphinx; extra == "docs"
23
27
  Requires-Dist: sphinx-rtd-theme; extra == "docs"
@@ -35,7 +39,7 @@ Requires-Dist: pre-commit; extra == "dev"
35
39
  Requires-Dist: pyproject-fmt; extra == "dev"
36
40
  Dynamic: license-file
37
41
 
38
- # Maxlotlib
42
+ # Maxplotlib
39
43
 
40
44
 
41
45
  # Maxplotlib
@@ -256,23 +260,29 @@ canvas.show(backend="tikzfigure")
256
260
 
257
261
  ![](README_files/figure-commonmark/cell-14-output-1.png)
258
262
 
259
- ### Horizontal Subplots with TikZ Backend
263
+ ### Subplots and Meshes with the TikZ Backend
260
264
 
261
- The tikzfigure backend supports creating side-by-side subplots (1×n
262
- layouts):
265
+ The tikzfigure backend draws the canvas with Matplotlib and converts the
266
+ drawn figure into pgfplots axes, so every layout converts (rows, columns,
267
+ grids, twin axes), with LaTeX text, legends and colorbars. Lines,
268
+ markers, bars and text are pgfplots code; meshes and images are included
269
+ as images:
263
270
 
264
271
  ``` python
265
272
  x = np.linspace(0, 2 * np.pi, 200)
266
- canvas, (ax1, ax2) = Canvas.subplots(ncols=2, width="10cm", ratio=0.3)
273
+ canvas, (ax1, ax2) = Canvas.subplots(ncols=2, width="12cm", ratio=0.45)
267
274
 
268
- ax1.plot(x, np.sin(x), color="royalblue")
269
- ax1.set_title("sin(x)")
275
+ ax1.plot(x, np.sin(x), color="royalblue", label="$\\sin x$")
276
+ ax1.plot(x, np.cos(x), color="tomato", label="$\\cos x$")
277
+ ax1.set_title("Lines")
278
+ ax1.set_legend(True)
270
279
 
271
- ax2.plot(x, np.cos(x), color="tomato")
272
- ax2.set_title("cos(x)")
280
+ xx, yy = np.meshgrid(x, x)
281
+ ax2.pcolormesh(xx, yy, np.sin(xx) * np.cos(yy), cmap="RdBu_r")
282
+ ax2.add_colorbar(label="$\\sin x \\cos y$")
283
+ ax2.set_title("A mesh")
273
284
 
274
- canvas.suptitle("Trigonometric Functions")
275
- canvas.show(backend="tikzfigure") # Generates LaTeX subfigures
285
+ canvas.show(backend="tikzfigure") # compiles with pdflatex
276
286
  ```
277
287
 
278
288
  <div id="fig-showcase-subplots">
@@ -285,9 +295,11 @@ Figure 2
285
295
 
286
296
  </div>
287
297
 
288
- **Note:** Only horizontal layouts (1×n) are currently supported with the
289
- tikzfigure backend. Vertical/grid layouts will raise
290
- `NotImplementedError`. See the tutorials for more examples.
298
+ `canvas.render(backend="tikzfigure").savefig("figure.tikz")` writes the
299
+ code for `\\input` in a LaTeX document, with the images next to it. Any
300
+ Matplotlib figure converts the same way with
301
+ `maxplotlib.backends.tikzfigure.figure_to_tikz(fig)`. See the tutorials
302
+ for more examples.
291
303
 
292
304
  ### Terminal Backend with plotext
293
305
 
@@ -386,3 +398,49 @@ canvas.show()
386
398
 
387
399
  (<Figure size 590.551x324.803 with 1 Axes>,
388
400
  array([[<Axes: xlabel='x'>]], dtype=object))
401
+
402
+ ### xarray data
403
+
404
+ Plot labelled [xarray](https://docs.xarray.dev) data directly
405
+ (`pip install maxplotlibx[xarray]`). Axes come from the coordinates,
406
+ labels from the `long_name` and `units` attributes, and titles from the
407
+ coordinates you selected. `import maxplotlib.xarray` adds a `.maxplot`
408
+ accessor that mirrors xarray’s own `.plot` API and returns an ordinary
409
+ `Canvas`, so the backend is still chosen when rendering:
410
+
411
+ ``` python
412
+ import xarray as xr
413
+
414
+ import maxplotlib.xarray # registers da.maxplot and ds.maxplot
415
+
416
+ t = np.linspace(0, 1.5, 6)
417
+ xs = np.linspace(0, 2 * np.pi, 80)
418
+ ys = np.linspace(-1, 1, 50)
419
+ wave = xr.DataArray(
420
+ np.sin(xs - 2 * t[:, None, None]) * np.exp(-3 * ys[None, :, None] ** 2),
421
+ dims=("t", "y", "x"),
422
+ coords={"t": ("t", t, {"units": "s"}), "y": ys, "x": ("x", xs, {"units": "m"})},
423
+ name="phi",
424
+ attrs={"long_name": "Potential", "units": "V"},
425
+ )
426
+
427
+ wave.maxplot.pcolormesh(col="t", col_wrap=3, canvas_kwargs={"width": "16cm", "ratio": 0.6}).show()
428
+ ```
429
+
430
+ ![](README_files/figure-commonmark/cell-20-output-1.png)
431
+
432
+ (<Figure size 944.882x566.929 with 7 Axes>,
433
+ array([[<Axes: title={'center': 't = 0 s'}, ylabel='y'>,
434
+ <Axes: title={'center': 't = 0.3 s'}>,
435
+ <Axes: title={'center': 't = 0.6 s'}>],
436
+ [<Axes: title={'center': 't = 0.9 s'}, xlabel='x [m]', ylabel='y'>,
437
+ <Axes: title={'center': 't = 1.2 s'}, xlabel='x [m]'>,
438
+ <Axes: title={'center': 't = 1.5 s'}, xlabel='x [m]'>]],
439
+ dtype=object))
440
+
441
+ The same works through Canvas methods,
442
+ e.g. `canvas.plot(da, hue="species")`,
443
+ `ax.pcolormesh(da, xcoord="R", ycoord="Z")` for curvilinear grids, or
444
+ `Canvas.facet(da, col="t")`. `ds.maxplot.scatter(x=..., y=..., hue=...)`
445
+ plots one Dataset variable against another. See the [xarray
446
+ tutorial](tutorials/tutorial_17_xarray.ipynb) for more.
@@ -1,4 +1,4 @@
1
- # Maxlotlib
1
+ # Maxplotlib
2
2
 
3
3
 
4
4
  # Maxplotlib
@@ -219,23 +219,29 @@ canvas.show(backend="tikzfigure")
219
219
 
220
220
  ![](README_files/figure-commonmark/cell-14-output-1.png)
221
221
 
222
- ### Horizontal Subplots with TikZ Backend
222
+ ### Subplots and Meshes with the TikZ Backend
223
223
 
224
- The tikzfigure backend supports creating side-by-side subplots (1×n
225
- layouts):
224
+ The tikzfigure backend draws the canvas with Matplotlib and converts the
225
+ drawn figure into pgfplots axes, so every layout converts (rows, columns,
226
+ grids, twin axes), with LaTeX text, legends and colorbars. Lines,
227
+ markers, bars and text are pgfplots code; meshes and images are included
228
+ as images:
226
229
 
227
230
  ``` python
228
231
  x = np.linspace(0, 2 * np.pi, 200)
229
- canvas, (ax1, ax2) = Canvas.subplots(ncols=2, width="10cm", ratio=0.3)
232
+ canvas, (ax1, ax2) = Canvas.subplots(ncols=2, width="12cm", ratio=0.45)
230
233
 
231
- ax1.plot(x, np.sin(x), color="royalblue")
232
- ax1.set_title("sin(x)")
234
+ ax1.plot(x, np.sin(x), color="royalblue", label="$\\sin x$")
235
+ ax1.plot(x, np.cos(x), color="tomato", label="$\\cos x$")
236
+ ax1.set_title("Lines")
237
+ ax1.set_legend(True)
233
238
 
234
- ax2.plot(x, np.cos(x), color="tomato")
235
- ax2.set_title("cos(x)")
239
+ xx, yy = np.meshgrid(x, x)
240
+ ax2.pcolormesh(xx, yy, np.sin(xx) * np.cos(yy), cmap="RdBu_r")
241
+ ax2.add_colorbar(label="$\\sin x \\cos y$")
242
+ ax2.set_title("A mesh")
236
243
 
237
- canvas.suptitle("Trigonometric Functions")
238
- canvas.show(backend="tikzfigure") # Generates LaTeX subfigures
244
+ canvas.show(backend="tikzfigure") # compiles with pdflatex
239
245
  ```
240
246
 
241
247
  <div id="fig-showcase-subplots">
@@ -248,9 +254,11 @@ Figure 2
248
254
 
249
255
  </div>
250
256
 
251
- **Note:** Only horizontal layouts (1×n) are currently supported with the
252
- tikzfigure backend. Vertical/grid layouts will raise
253
- `NotImplementedError`. See the tutorials for more examples.
257
+ `canvas.render(backend="tikzfigure").savefig("figure.tikz")` writes the
258
+ code for `\\input` in a LaTeX document, with the images next to it. Any
259
+ Matplotlib figure converts the same way with
260
+ `maxplotlib.backends.tikzfigure.figure_to_tikz(fig)`. See the tutorials
261
+ for more examples.
254
262
 
255
263
  ### Terminal Backend with plotext
256
264
 
@@ -349,3 +357,49 @@ canvas.show()
349
357
 
350
358
  (<Figure size 590.551x324.803 with 1 Axes>,
351
359
  array([[<Axes: xlabel='x'>]], dtype=object))
360
+
361
+ ### xarray data
362
+
363
+ Plot labelled [xarray](https://docs.xarray.dev) data directly
364
+ (`pip install maxplotlibx[xarray]`). Axes come from the coordinates,
365
+ labels from the `long_name` and `units` attributes, and titles from the
366
+ coordinates you selected. `import maxplotlib.xarray` adds a `.maxplot`
367
+ accessor that mirrors xarray’s own `.plot` API and returns an ordinary
368
+ `Canvas`, so the backend is still chosen when rendering:
369
+
370
+ ``` python
371
+ import xarray as xr
372
+
373
+ import maxplotlib.xarray # registers da.maxplot and ds.maxplot
374
+
375
+ t = np.linspace(0, 1.5, 6)
376
+ xs = np.linspace(0, 2 * np.pi, 80)
377
+ ys = np.linspace(-1, 1, 50)
378
+ wave = xr.DataArray(
379
+ np.sin(xs - 2 * t[:, None, None]) * np.exp(-3 * ys[None, :, None] ** 2),
380
+ dims=("t", "y", "x"),
381
+ coords={"t": ("t", t, {"units": "s"}), "y": ys, "x": ("x", xs, {"units": "m"})},
382
+ name="phi",
383
+ attrs={"long_name": "Potential", "units": "V"},
384
+ )
385
+
386
+ wave.maxplot.pcolormesh(col="t", col_wrap=3, canvas_kwargs={"width": "16cm", "ratio": 0.6}).show()
387
+ ```
388
+
389
+ ![](README_files/figure-commonmark/cell-20-output-1.png)
390
+
391
+ (<Figure size 944.882x566.929 with 7 Axes>,
392
+ array([[<Axes: title={'center': 't = 0 s'}, ylabel='y'>,
393
+ <Axes: title={'center': 't = 0.3 s'}>,
394
+ <Axes: title={'center': 't = 0.6 s'}>],
395
+ [<Axes: title={'center': 't = 0.9 s'}, xlabel='x [m]', ylabel='y'>,
396
+ <Axes: title={'center': 't = 1.2 s'}, xlabel='x [m]'>,
397
+ <Axes: title={'center': 't = 1.5 s'}, xlabel='x [m]'>]],
398
+ dtype=object))
399
+
400
+ The same works through Canvas methods,
401
+ e.g. `canvas.plot(da, hue="species")`,
402
+ `ax.pcolormesh(da, xcoord="R", ycoord="Z")` for curvilinear grids, or
403
+ `Canvas.facet(da, col="t")`. `ds.maxplot.scatter(x=..., y=..., hue=...)`
404
+ plots one Dataset variable against another. See the [xarray
405
+ tutorial](tutorials/tutorial_17_xarray.ipynb) for more.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "maxplotlibx"
7
- version = "0.1.8"
7
+ version = "0.2.0"
8
8
  description = "A reproducible plotting module with various backends and export options."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -19,14 +19,19 @@ dependencies = [
19
19
  "pint",
20
20
  "plotly",
21
21
  "plotext >= 6.0, < 7",
22
- "tikzfigure[vis]>=0.3.0",
22
+ "tikzfigure[vis]>=0.4.0",
23
23
  ]
24
24
  [project.optional-dependencies]
25
25
  test = [
26
26
  "pytest",
27
27
  "coverage",
28
+ "xarray",
29
+ ]
30
+ xarray = [
31
+ "xarray",
28
32
  ]
29
33
  docs = [
34
+ "xarray",
30
35
  "myst-parser",
31
36
  "sphinx",
32
37
  "sphinx-rtd-theme",
@@ -0,0 +1,342 @@
1
+ """Detached Matplotlib state used by the object importer.
2
+
3
+ Transforms must be rebound, not frozen in source display coordinates. The
4
+ snapshot memo cuts ownership links before copying and replaces them with tokens;
5
+ each render binds those tokens to its new figure/axes. No source artists are
6
+ reparented, and repeated renders do not share mutable Matplotlib state.
7
+ """
8
+
9
+ import copy
10
+ from dataclasses import asdict, dataclass, field
11
+
12
+ from matplotlib.artist import Artist
13
+ from matplotlib.cbook import CallbackRegistry
14
+ from matplotlib.collections import Collection
15
+ from matplotlib.image import AxesImage
16
+ from matplotlib.lines import Line2D
17
+ from matplotlib.patches import Patch
18
+ from matplotlib.table import Table
19
+ from matplotlib.text import Text
20
+ from matplotlib.transforms import Bbox, BboxTransformTo
21
+
22
+
23
+ @dataclass
24
+ class ImportDiagnostic:
25
+ artist_id: int
26
+ artist_type: str
27
+ label: str
28
+ severity: str
29
+ message: str
30
+ fallback: str | None = None
31
+ backends: tuple = ("matplotlib",)
32
+
33
+
34
+ @dataclass
35
+ class ImportReport:
36
+ """Import decisions, including losses and backend-specific representations."""
37
+
38
+ diagnostics: list = field(default_factory=list)
39
+
40
+ def add(
41
+ self,
42
+ artist,
43
+ message,
44
+ *,
45
+ severity="info",
46
+ fallback=None,
47
+ backends=("matplotlib",),
48
+ ):
49
+ item = ImportDiagnostic(
50
+ id(artist),
51
+ type(artist).__name__,
52
+ str(getattr(artist, "get_label", lambda: "")()),
53
+ severity,
54
+ message,
55
+ fallback,
56
+ backends,
57
+ )
58
+ self.diagnostics.append(item)
59
+ return item
60
+
61
+ def to_dict(self):
62
+ return asdict(self)
63
+
64
+
65
+ def root_figure(figure):
66
+ while getattr(figure, "figure", figure) is not figure:
67
+ figure = figure.figure
68
+ return figure
69
+
70
+
71
+ def figure_bounds(ax, figure):
72
+ return tuple(
73
+ ax.get_position(original=True)
74
+ .transformed(ax.figure.transSubfigure)
75
+ .transformed(figure.transFigure.inverted())
76
+ .bounds
77
+ )
78
+
79
+
80
+ def _bindings(ax, fig):
81
+ bindings = {
82
+ "figure": fig,
83
+ "transFigure": fig.transFigure,
84
+ "transSubfigure": fig.transSubfigure,
85
+ "dpi_scale_trans": fig.dpi_scale_trans,
86
+ "fig_bbox": fig.bbox,
87
+ }
88
+ if ax is not None:
89
+ bindings.update(
90
+ axes=ax,
91
+ transData=ax.transData,
92
+ transAxes=ax.transAxes,
93
+ transScale=ax.transScale,
94
+ transLimits=ax.transLimits,
95
+ ax_bbox=ax.bbox,
96
+ xaxis=ax.xaxis,
97
+ yaxis=ax.yaxis,
98
+ xaxis_transform=ax.get_xaxis_transform(),
99
+ yaxis_transform=ax.get_yaxis_transform(),
100
+ )
101
+ return bindings
102
+
103
+
104
+ class _BindingToken:
105
+ """Weak-referenceable marker for copied Transform parent links."""
106
+
107
+
108
+ class ReboundSnapshot:
109
+ """Copy a payload without retaining its owning axes, figure or callbacks."""
110
+
111
+ def __init__(self, payload, ax=None, fig=None):
112
+ owner = fig if fig is not None else ax.figure
113
+ fig = root_figure(owner)
114
+ self.owner_bounds = None
115
+ self.tokens = {}
116
+ memo = {}
117
+ bindings = _bindings(ax, fig)
118
+ if owner is not fig:
119
+ self.owner_bounds = tuple(
120
+ owner.bbox.transformed(fig.transFigure.inverted()).bounds
121
+ )
122
+ bindings.update(owner=owner, owner_transform=owner.transSubfigure)
123
+ for name, value in bindings.items():
124
+ # Some canonical transforms are aliases of one another.
125
+ if id(value) not in memo:
126
+ self.tokens[name] = memo[id(value)] = _BindingToken()
127
+ artists = []
128
+ if isinstance(payload, Artist):
129
+ artists = payload.findobj()
130
+ for artist in artists:
131
+ for name in ("_remove_method", "stale_callback"):
132
+ value = getattr(artist, name, None)
133
+ if value is not None:
134
+ memo[id(value)] = None
135
+ callbacks = getattr(artist, "_callbacks", None)
136
+ if callbacks is not None:
137
+ memo[id(callbacks)] = CallbackRegistry()
138
+ self.payload = copy.deepcopy(payload, memo)
139
+
140
+ def clone(self, ax=None, fig=None):
141
+ fig = fig if fig is not None else ax.figure
142
+ bindings = _bindings(ax, fig)
143
+ if self.owner_bounds is not None:
144
+ bindings.update(
145
+ owner=fig,
146
+ owner_transform=BboxTransformTo(Bbox.from_bounds(*self.owner_bounds))
147
+ + fig.transFigure,
148
+ )
149
+ return copy.deepcopy(
150
+ self.payload,
151
+ {id(token): bindings[name] for name, token in self.tokens.items()},
152
+ )
153
+
154
+ def draw(self, ax, **overrides):
155
+ artist = self.clone(ax)
156
+ artist.set(**overrides)
157
+ clipbox, clippath = artist.get_clip_box(), artist.get_clip_path()
158
+ # add_* establishes the new removal and stale callbacks.
159
+ if isinstance(artist, Collection):
160
+ ax.add_collection(artist, autolim=False)
161
+ elif isinstance(artist, Line2D):
162
+ ax.add_line(artist)
163
+ elif isinstance(artist, Patch):
164
+ ax.add_patch(artist)
165
+ elif isinstance(artist, AxesImage):
166
+ ax.add_image(artist)
167
+ elif isinstance(artist, Table):
168
+ ax.add_table(artist)
169
+ elif isinstance(artist, Text):
170
+ ax._add_text(artist)
171
+ else:
172
+ ax.add_artist(artist)
173
+ artist.set_clip_path(clippath)
174
+ artist.set_clip_box(clipbox)
175
+ return artist
176
+
177
+
178
+ def capture_axis_state(ax):
179
+ """Capture decorations which have no backend-neutral equivalent yet."""
180
+ state = {"axes": {}, "spines": {}, "titles": {}}
181
+ for name in ("x", "y"):
182
+ axis = getattr(ax, name + "axis")
183
+ state["axes"][name] = dict(
184
+ scale=ReboundSnapshot(axis._scale, ax),
185
+ major_locator=ReboundSnapshot(axis.get_major_locator(), ax),
186
+ minor_locator=ReboundSnapshot(axis.get_minor_locator(), ax),
187
+ major_formatter=ReboundSnapshot(axis.get_major_formatter(), ax),
188
+ minor_formatter=ReboundSnapshot(axis.get_minor_formatter(), ax),
189
+ units=copy.deepcopy(axis.get_units()),
190
+ converter=copy.deepcopy(
191
+ axis.get_converter()
192
+ if hasattr(axis, "get_converter")
193
+ else axis.converter
194
+ ),
195
+ major_kw=_tick_params(axis, "major"),
196
+ minor_kw=_tick_params(axis, "minor"),
197
+ ticks={
198
+ which: [
199
+ _tick_style(tick)
200
+ for tick in getattr(axis, "get_" + which + "_ticks")()
201
+ ]
202
+ for which in ("major", "minor")
203
+ },
204
+ label_position=axis.get_label_position(),
205
+ offset_style=_text_properties(axis.get_offset_text()),
206
+ )
207
+ for name, spine in ax.spines.items():
208
+ state["spines"][name] = dict(
209
+ visible=spine.get_visible(),
210
+ edgecolor=spine.get_edgecolor(),
211
+ linewidth=spine.get_linewidth(),
212
+ linestyle=spine.get_linestyle(),
213
+ bounds=spine.get_bounds(),
214
+ )
215
+ if spine.spine_type in ("left", "right", "top", "bottom"):
216
+ state["spines"][name]["position"] = copy.deepcopy(spine.get_position())
217
+ for loc, title in (
218
+ ("left", ax._left_title),
219
+ ("center", ax.title),
220
+ ("right", ax._right_title),
221
+ ):
222
+ state["titles"][loc] = (
223
+ title.get_text(),
224
+ _text_properties(title),
225
+ title.get_position(),
226
+ )
227
+ state["autotitlepos"] = ax._autotitlepos
228
+ state["label_coords"] = {
229
+ name: (
230
+ axis.label.get_position(),
231
+ ReboundSnapshot(axis.label.get_transform(), ax),
232
+ )
233
+ for name, axis in (("x", ax.xaxis), ("y", ax.yaxis))
234
+ if not axis._autolabelpos
235
+ }
236
+ return state
237
+
238
+
239
+ def _text_properties(text):
240
+ return dict(
241
+ fontproperties=copy.deepcopy(text.get_fontproperties()),
242
+ color=text.get_color(),
243
+ rotation=text.get_rotation(),
244
+ rotation_mode=text.get_rotation_mode(),
245
+ horizontalalignment=text.get_ha(),
246
+ verticalalignment=text.get_va(),
247
+ visible=text.get_visible(),
248
+ usetex=text.get_usetex(),
249
+ )
250
+
251
+
252
+ def _tick_params(axis, which):
253
+ tick = getattr(axis, "get_" + which + "_ticks")(1)[0]
254
+ defaults = dict(
255
+ length=tick._size,
256
+ width=tick._width,
257
+ pad=tick._base_pad,
258
+ direction=tick._tickdir,
259
+ color=tick.tick1line.get_color(),
260
+ )
261
+ defaults.update(copy.deepcopy(getattr(axis, "_" + which + "_tick_kw")))
262
+ return defaults
263
+
264
+
265
+ def _tick_line_style(line):
266
+ return {
267
+ name: getattr(line, "get_" + name)()
268
+ for name in (
269
+ "color",
270
+ "marker",
271
+ "markersize",
272
+ "markeredgewidth",
273
+ "markeredgecolor",
274
+ "visible",
275
+ "zorder",
276
+ )
277
+ }
278
+
279
+
280
+ def _tick_style(tick):
281
+ return dict(
282
+ tick1line=_tick_line_style(tick.tick1line),
283
+ tick2line=_tick_line_style(tick.tick2line),
284
+ label1=_text_properties(tick.label1),
285
+ label2=_text_properties(tick.label2),
286
+ gridline=dict(
287
+ visible=tick.gridline.get_visible(),
288
+ color=tick.gridline.get_color(),
289
+ linewidth=tick.gridline.get_linewidth(),
290
+ linestyle=tick.gridline.get_linestyle(),
291
+ alpha=tick.gridline.get_alpha(),
292
+ ),
293
+ )
294
+
295
+
296
+ def apply_axis_state(ax, state, *, units=True):
297
+ for name, props in state["spines"].items():
298
+ props = copy.deepcopy(props)
299
+ bounds = props.pop("bounds")
300
+ ax.spines[name].set(**props)
301
+ if bounds is not None:
302
+ ax.spines[name].set_bounds(*bounds)
303
+ for name, settings in state["axes"].items():
304
+ axis = getattr(ax, name + "axis")
305
+ # Scales are installed before locators/formatters: changing a scale
306
+ # installs defaults and would otherwise erase the imported tick setup.
307
+ getattr(ax, "set_" + name + "scale")(settings["scale"].clone(ax))
308
+ if units:
309
+ converter = copy.deepcopy(settings["converter"])
310
+ if hasattr(axis, "set_converter"):
311
+ if not getattr(axis, "_converter_is_explicit", False):
312
+ axis.set_converter(converter)
313
+ else:
314
+ axis.converter = converter
315
+ axis.set_units(copy.deepcopy(settings["units"]))
316
+ for kind in ("major", "minor"):
317
+ getattr(axis, "set_" + kind + "_locator")(
318
+ settings[kind + "_locator"].clone(ax)
319
+ )
320
+ getattr(axis, "set_" + kind + "_formatter")(
321
+ settings[kind + "_formatter"].clone(ax)
322
+ )
323
+ axis.set_tick_params(which=kind, **copy.deepcopy(settings[kind + "_kw"]))
324
+ ticks = getattr(axis, "get_" + kind + "_ticks")()
325
+ styles = settings["ticks"][kind]
326
+ for i, tick in enumerate(ticks):
327
+ if styles:
328
+ style = styles[min(i, len(styles) - 1)]
329
+ for part, props in style.items():
330
+ getattr(tick, part).set(**props)
331
+ axis.set_label_position(settings["label_position"])
332
+ axis.get_offset_text().set(**settings["offset_style"])
333
+ for loc, (text, props, position) in (
334
+ state["titles"].items() if hasattr(ax, "set_title") else ()
335
+ ):
336
+ title = ax.set_title(text, loc=loc, **props)
337
+ title.set_position(position)
338
+ ax._autotitlepos = state["autotitlepos"]
339
+ for name, (position, transform) in state["label_coords"].items():
340
+ getattr(ax, name + "axis").set_label_coords(
341
+ *position, transform=transform.clone(ax)
342
+ )