figrecipe 0.6.0__py3-none-any.whl → 0.7.4__py3-none-any.whl

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 (177) hide show
  1. figrecipe/__init__.py +106 -973
  2. figrecipe/_api/__init__.py +48 -0
  3. figrecipe/_api/_extract.py +108 -0
  4. figrecipe/_api/_notebook.py +61 -0
  5. figrecipe/_api/_panel.py +46 -0
  6. figrecipe/_api/_save.py +191 -0
  7. figrecipe/_api/_seaborn_proxy.py +34 -0
  8. figrecipe/_api/_style_manager.py +153 -0
  9. figrecipe/_api/_subplots.py +333 -0
  10. figrecipe/_api/_validate.py +82 -0
  11. figrecipe/_dev/__init__.py +2 -93
  12. figrecipe/_dev/_plotters.py +76 -0
  13. figrecipe/_dev/_run_demos.py +56 -0
  14. figrecipe/_dev/demo_plotters/__init__.py +35 -166
  15. figrecipe/_dev/demo_plotters/_categories.py +81 -0
  16. figrecipe/_dev/demo_plotters/_figure_creators.py +119 -0
  17. figrecipe/_dev/demo_plotters/_helpers.py +31 -0
  18. figrecipe/_dev/demo_plotters/_registry.py +50 -0
  19. figrecipe/_dev/demo_plotters/bar_categorical/__init__.py +4 -0
  20. figrecipe/_dev/demo_plotters/contour_surface/__init__.py +4 -0
  21. figrecipe/_dev/demo_plotters/distribution/__init__.py +4 -0
  22. figrecipe/_dev/demo_plotters/image_matrix/__init__.py +4 -0
  23. figrecipe/_dev/demo_plotters/line_curve/__init__.py +4 -0
  24. figrecipe/_dev/demo_plotters/{plot_plot.py → line_curve/plot_plot.py} +3 -2
  25. figrecipe/_dev/demo_plotters/scatter_points/__init__.py +4 -0
  26. figrecipe/_dev/demo_plotters/special/__init__.py +4 -0
  27. figrecipe/_dev/demo_plotters/{plot_pie.py → special/plot_pie.py} +5 -1
  28. figrecipe/_dev/demo_plotters/spectral_signal/__init__.py +4 -0
  29. figrecipe/_dev/demo_plotters/vector_flow/__init__.py +4 -0
  30. figrecipe/_editor/__init__.py +57 -9
  31. figrecipe/_editor/_bbox/__init__.py +43 -0
  32. figrecipe/_editor/_bbox/_collections.py +177 -0
  33. figrecipe/_editor/_bbox/_elements.py +159 -0
  34. figrecipe/_editor/_bbox/_extract.py +256 -0
  35. figrecipe/_editor/_bbox/_extract_axes.py +370 -0
  36. figrecipe/_editor/_bbox/_extract_text.py +342 -0
  37. figrecipe/_editor/_bbox/_lines.py +173 -0
  38. figrecipe/_editor/_bbox/_transforms.py +146 -0
  39. figrecipe/_editor/_flask_app.py +68 -1039
  40. figrecipe/_editor/_helpers.py +242 -0
  41. figrecipe/_editor/_hitmap/__init__.py +76 -0
  42. figrecipe/_editor/_hitmap/_artists/__init__.py +21 -0
  43. figrecipe/_editor/_hitmap/_artists/_collections.py +345 -0
  44. figrecipe/_editor/_hitmap/_artists/_images.py +68 -0
  45. figrecipe/_editor/_hitmap/_artists/_lines.py +107 -0
  46. figrecipe/_editor/_hitmap/_artists/_patches.py +163 -0
  47. figrecipe/_editor/_hitmap/_artists/_text.py +190 -0
  48. figrecipe/_editor/_hitmap/_colors.py +181 -0
  49. figrecipe/_editor/_hitmap/_detect.py +137 -0
  50. figrecipe/_editor/_hitmap/_restore.py +154 -0
  51. figrecipe/_editor/_hitmap_main.py +182 -0
  52. figrecipe/_editor/_preferences.py +135 -0
  53. figrecipe/_editor/_render_overrides.py +480 -0
  54. figrecipe/_editor/_renderer.py +35 -185
  55. figrecipe/_editor/_routes_axis.py +453 -0
  56. figrecipe/_editor/_routes_core.py +284 -0
  57. figrecipe/_editor/_routes_element.py +317 -0
  58. figrecipe/_editor/_routes_style.py +223 -0
  59. figrecipe/_editor/_templates/__init__.py +78 -1
  60. figrecipe/_editor/_templates/_html.py +109 -13
  61. figrecipe/_editor/_templates/_scripts/__init__.py +120 -0
  62. figrecipe/_editor/_templates/_scripts/_api.py +228 -0
  63. figrecipe/_editor/_templates/_scripts/_colors.py +485 -0
  64. figrecipe/_editor/_templates/_scripts/_core.py +436 -0
  65. figrecipe/_editor/_templates/_scripts/_debug_snapshot.py +186 -0
  66. figrecipe/_editor/_templates/_scripts/_element_editor.py +310 -0
  67. figrecipe/_editor/_templates/_scripts/_files.py +195 -0
  68. figrecipe/_editor/_templates/_scripts/_hitmap.py +509 -0
  69. figrecipe/_editor/_templates/_scripts/_inspector.py +315 -0
  70. figrecipe/_editor/_templates/_scripts/_labels.py +464 -0
  71. figrecipe/_editor/_templates/_scripts/_legend_drag.py +265 -0
  72. figrecipe/_editor/_templates/_scripts/_modals.py +226 -0
  73. figrecipe/_editor/_templates/_scripts/_overlays.py +292 -0
  74. figrecipe/_editor/_templates/_scripts/_panel_drag.py +334 -0
  75. figrecipe/_editor/_templates/_scripts/_panel_position.py +279 -0
  76. figrecipe/_editor/_templates/_scripts/_selection.py +237 -0
  77. figrecipe/_editor/_templates/_scripts/_tabs.py +89 -0
  78. figrecipe/_editor/_templates/_scripts/_view_mode.py +107 -0
  79. figrecipe/_editor/_templates/_scripts/_zoom.py +179 -0
  80. figrecipe/_editor/_templates/_styles/__init__.py +69 -0
  81. figrecipe/_editor/_templates/_styles/_base.py +64 -0
  82. figrecipe/_editor/_templates/_styles/_buttons.py +206 -0
  83. figrecipe/_editor/_templates/_styles/_color_input.py +123 -0
  84. figrecipe/_editor/_templates/_styles/_controls.py +265 -0
  85. figrecipe/_editor/_templates/_styles/_dynamic_props.py +144 -0
  86. figrecipe/_editor/_templates/_styles/_forms.py +126 -0
  87. figrecipe/_editor/_templates/_styles/_hitmap.py +184 -0
  88. figrecipe/_editor/_templates/_styles/_inspector.py +90 -0
  89. figrecipe/_editor/_templates/_styles/_labels.py +118 -0
  90. figrecipe/_editor/_templates/_styles/_modals.py +98 -0
  91. figrecipe/_editor/_templates/_styles/_overlays.py +130 -0
  92. figrecipe/_editor/_templates/_styles/_preview.py +225 -0
  93. figrecipe/_editor/_templates/_styles/_selection.py +73 -0
  94. figrecipe/_params/_DECORATION_METHODS.py +6 -0
  95. figrecipe/_recorder.py +35 -106
  96. figrecipe/_recorder_utils.py +124 -0
  97. figrecipe/_reproducer/__init__.py +18 -0
  98. figrecipe/_reproducer/_core.py +498 -0
  99. figrecipe/_reproducer/_custom_plots.py +279 -0
  100. figrecipe/_reproducer/_seaborn.py +100 -0
  101. figrecipe/_reproducer/_violin.py +186 -0
  102. figrecipe/_signatures/_kwargs.py +273 -0
  103. figrecipe/_signatures/_loader.py +21 -423
  104. figrecipe/_signatures/_parsing.py +147 -0
  105. figrecipe/_wrappers/_axes.py +119 -910
  106. figrecipe/_wrappers/_axes_helpers.py +136 -0
  107. figrecipe/_wrappers/_axes_plots.py +418 -0
  108. figrecipe/_wrappers/_axes_seaborn.py +157 -0
  109. figrecipe/_wrappers/_figure.py +162 -0
  110. figrecipe/_wrappers/_panel_labels.py +127 -0
  111. figrecipe/_wrappers/_plot_helpers.py +143 -0
  112. figrecipe/_wrappers/_violin_helpers.py +180 -0
  113. figrecipe/styles/__init__.py +8 -6
  114. figrecipe/styles/_dotdict.py +72 -0
  115. figrecipe/styles/_finalize.py +134 -0
  116. figrecipe/styles/_fonts.py +77 -0
  117. figrecipe/styles/_kwargs_converter.py +178 -0
  118. figrecipe/styles/_plot_styles.py +209 -0
  119. figrecipe/styles/_style_applier.py +32 -478
  120. figrecipe/styles/_style_loader.py +16 -192
  121. figrecipe/styles/_themes.py +151 -0
  122. figrecipe/styles/presets/MATPLOTLIB.yaml +2 -1
  123. figrecipe/styles/presets/SCITEX.yaml +29 -24
  124. {figrecipe-0.6.0.dist-info → figrecipe-0.7.4.dist-info}/METADATA +37 -2
  125. figrecipe-0.7.4.dist-info/RECORD +188 -0
  126. figrecipe/_editor/_bbox.py +0 -978
  127. figrecipe/_editor/_hitmap.py +0 -937
  128. figrecipe/_editor/_templates/_scripts.py +0 -2778
  129. figrecipe/_editor/_templates/_styles.py +0 -1326
  130. figrecipe/_reproducer.py +0 -975
  131. figrecipe-0.6.0.dist-info/RECORD +0 -90
  132. /figrecipe/_dev/demo_plotters/{plot_bar.py → bar_categorical/plot_bar.py} +0 -0
  133. /figrecipe/_dev/demo_plotters/{plot_barh.py → bar_categorical/plot_barh.py} +0 -0
  134. /figrecipe/_dev/demo_plotters/{plot_contour.py → contour_surface/plot_contour.py} +0 -0
  135. /figrecipe/_dev/demo_plotters/{plot_contourf.py → contour_surface/plot_contourf.py} +0 -0
  136. /figrecipe/_dev/demo_plotters/{plot_tricontour.py → contour_surface/plot_tricontour.py} +0 -0
  137. /figrecipe/_dev/demo_plotters/{plot_tricontourf.py → contour_surface/plot_tricontourf.py} +0 -0
  138. /figrecipe/_dev/demo_plotters/{plot_tripcolor.py → contour_surface/plot_tripcolor.py} +0 -0
  139. /figrecipe/_dev/demo_plotters/{plot_triplot.py → contour_surface/plot_triplot.py} +0 -0
  140. /figrecipe/_dev/demo_plotters/{plot_boxplot.py → distribution/plot_boxplot.py} +0 -0
  141. /figrecipe/_dev/demo_plotters/{plot_ecdf.py → distribution/plot_ecdf.py} +0 -0
  142. /figrecipe/_dev/demo_plotters/{plot_hist.py → distribution/plot_hist.py} +0 -0
  143. /figrecipe/_dev/demo_plotters/{plot_hist2d.py → distribution/plot_hist2d.py} +0 -0
  144. /figrecipe/_dev/demo_plotters/{plot_violinplot.py → distribution/plot_violinplot.py} +0 -0
  145. /figrecipe/_dev/demo_plotters/{plot_hexbin.py → image_matrix/plot_hexbin.py} +0 -0
  146. /figrecipe/_dev/demo_plotters/{plot_imshow.py → image_matrix/plot_imshow.py} +0 -0
  147. /figrecipe/_dev/demo_plotters/{plot_matshow.py → image_matrix/plot_matshow.py} +0 -0
  148. /figrecipe/_dev/demo_plotters/{plot_pcolor.py → image_matrix/plot_pcolor.py} +0 -0
  149. /figrecipe/_dev/demo_plotters/{plot_pcolormesh.py → image_matrix/plot_pcolormesh.py} +0 -0
  150. /figrecipe/_dev/demo_plotters/{plot_spy.py → image_matrix/plot_spy.py} +0 -0
  151. /figrecipe/_dev/demo_plotters/{plot_errorbar.py → line_curve/plot_errorbar.py} +0 -0
  152. /figrecipe/_dev/demo_plotters/{plot_fill.py → line_curve/plot_fill.py} +0 -0
  153. /figrecipe/_dev/demo_plotters/{plot_fill_between.py → line_curve/plot_fill_between.py} +0 -0
  154. /figrecipe/_dev/demo_plotters/{plot_fill_betweenx.py → line_curve/plot_fill_betweenx.py} +0 -0
  155. /figrecipe/_dev/demo_plotters/{plot_stackplot.py → line_curve/plot_stackplot.py} +0 -0
  156. /figrecipe/_dev/demo_plotters/{plot_stairs.py → line_curve/plot_stairs.py} +0 -0
  157. /figrecipe/_dev/demo_plotters/{plot_step.py → line_curve/plot_step.py} +0 -0
  158. /figrecipe/_dev/demo_plotters/{plot_scatter.py → scatter_points/plot_scatter.py} +0 -0
  159. /figrecipe/_dev/demo_plotters/{plot_eventplot.py → special/plot_eventplot.py} +0 -0
  160. /figrecipe/_dev/demo_plotters/{plot_loglog.py → special/plot_loglog.py} +0 -0
  161. /figrecipe/_dev/demo_plotters/{plot_semilogx.py → special/plot_semilogx.py} +0 -0
  162. /figrecipe/_dev/demo_plotters/{plot_semilogy.py → special/plot_semilogy.py} +0 -0
  163. /figrecipe/_dev/demo_plotters/{plot_stem.py → special/plot_stem.py} +0 -0
  164. /figrecipe/_dev/demo_plotters/{plot_acorr.py → spectral_signal/plot_acorr.py} +0 -0
  165. /figrecipe/_dev/demo_plotters/{plot_angle_spectrum.py → spectral_signal/plot_angle_spectrum.py} +0 -0
  166. /figrecipe/_dev/demo_plotters/{plot_cohere.py → spectral_signal/plot_cohere.py} +0 -0
  167. /figrecipe/_dev/demo_plotters/{plot_csd.py → spectral_signal/plot_csd.py} +0 -0
  168. /figrecipe/_dev/demo_plotters/{plot_magnitude_spectrum.py → spectral_signal/plot_magnitude_spectrum.py} +0 -0
  169. /figrecipe/_dev/demo_plotters/{plot_phase_spectrum.py → spectral_signal/plot_phase_spectrum.py} +0 -0
  170. /figrecipe/_dev/demo_plotters/{plot_psd.py → spectral_signal/plot_psd.py} +0 -0
  171. /figrecipe/_dev/demo_plotters/{plot_specgram.py → spectral_signal/plot_specgram.py} +0 -0
  172. /figrecipe/_dev/demo_plotters/{plot_xcorr.py → spectral_signal/plot_xcorr.py} +0 -0
  173. /figrecipe/_dev/demo_plotters/{plot_barbs.py → vector_flow/plot_barbs.py} +0 -0
  174. /figrecipe/_dev/demo_plotters/{plot_quiver.py → vector_flow/plot_quiver.py} +0 -0
  175. /figrecipe/_dev/demo_plotters/{plot_streamplot.py → vector_flow/plot_streamplot.py} +0 -0
  176. {figrecipe-0.6.0.dist-info → figrecipe-0.7.4.dist-info}/WHEEL +0 -0
  177. {figrecipe-0.6.0.dist-info → figrecipe-0.7.4.dist-info}/licenses/LICENSE +0 -0
figrecipe/_reproducer.py DELETED
@@ -1,975 +0,0 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- """Reproduce figures from recipe files."""
4
-
5
- from pathlib import Path
6
- from typing import Any, Dict, List, Optional, Union
7
-
8
- import matplotlib.pyplot as plt
9
- import numpy as np
10
- from matplotlib.axes import Axes
11
-
12
- from ._recorder import CallRecord, FigureRecord
13
- from ._serializer import load_recipe
14
-
15
-
16
- def reproduce(
17
- path: Union[str, Path],
18
- calls: Optional[List[str]] = None,
19
- skip_decorations: bool = False,
20
- apply_overrides: bool = True,
21
- ):
22
- """Reproduce a figure from a recipe file.
23
-
24
- Parameters
25
- ----------
26
- path : str or Path
27
- Path to .yaml or .png recipe file. If .png is provided,
28
- the corresponding .yaml file will be loaded.
29
- calls : list of str, optional
30
- If provided, only reproduce these specific call IDs.
31
- skip_decorations : bool
32
- If True, skip decoration calls (labels, legends, etc.).
33
- apply_overrides : bool
34
- If True (default), apply .overrides.json if it exists.
35
- This preserves manual GUI editor changes.
36
-
37
- Returns
38
- -------
39
- fig : RecordingFigure
40
- Reproduced figure (same type as subplots() returns).
41
- axes : RecordingAxes or ndarray of RecordingAxes
42
- Reproduced axes (single if 1x1, otherwise numpy array).
43
-
44
- Examples
45
- --------
46
- >>> import figrecipe as ps
47
- >>> fig, ax = ps.reproduce("experiment_001.yaml")
48
- >>> fig, ax = ps.reproduce("experiment_001.png") # Also works
49
- >>> plt.show()
50
- """
51
- path = Path(path)
52
-
53
- # Accept both .png and .yaml - find the yaml file
54
- if path.suffix.lower() in (".png", ".jpg", ".jpeg", ".pdf", ".svg"):
55
- yaml_path = path.with_suffix(".yaml")
56
- if not yaml_path.exists():
57
- raise FileNotFoundError(
58
- f"Recipe file not found: {yaml_path}. "
59
- f"Expected .yaml file alongside {path}"
60
- )
61
- path = yaml_path
62
-
63
- record = load_recipe(path)
64
-
65
- # Check for override file and merge if exists
66
- if apply_overrides:
67
- overrides_path = path.with_suffix(".overrides.json")
68
- if overrides_path.exists():
69
- import json
70
-
71
- with open(overrides_path) as f:
72
- data = json.load(f)
73
-
74
- # Apply style overrides
75
- manual_overrides = data.get("manual_overrides", {})
76
- if manual_overrides:
77
- # Merge overrides into record style
78
- if record.style is None:
79
- record.style = {}
80
- record.style.update(manual_overrides)
81
-
82
- # Apply call overrides (kwargs changes from editor)
83
- call_overrides = data.get("call_overrides", {})
84
- if call_overrides:
85
- for ax_key, ax_record in record.axes.items():
86
- for call in ax_record.calls:
87
- if call.id in call_overrides:
88
- # Merge call kwargs overrides
89
- call.kwargs.update(call_overrides[call.id])
90
-
91
- return reproduce_from_record(
92
- record,
93
- calls=calls,
94
- skip_decorations=skip_decorations,
95
- )
96
-
97
-
98
- def reproduce_from_record(
99
- record: FigureRecord,
100
- calls: Optional[List[str]] = None,
101
- skip_decorations: bool = False,
102
- ):
103
- """Reproduce a figure from a FigureRecord.
104
-
105
- Parameters
106
- ----------
107
- record : FigureRecord
108
- The figure record to reproduce.
109
- calls : list of str, optional
110
- If provided, only reproduce these specific call IDs.
111
- skip_decorations : bool
112
- If True, skip decoration calls.
113
-
114
- Returns
115
- -------
116
- fig : RecordingFigure
117
- Reproduced figure (wrapped).
118
- axes : RecordingAxes or ndarray of RecordingAxes
119
- Reproduced axes (wrapped, numpy array for multi-axes).
120
- """
121
- from ._recorder import Recorder
122
- from ._wrappers import RecordingAxes, RecordingFigure
123
-
124
- # Determine grid size from axes positions
125
- max_row = 0
126
- max_col = 0
127
- for ax_key in record.axes.keys():
128
- parts = ax_key.split("_")
129
- if len(parts) >= 3:
130
- max_row = max(max_row, int(parts[1]))
131
- max_col = max(max_col, int(parts[2]))
132
-
133
- nrows = max_row + 1
134
- ncols = max_col + 1
135
-
136
- # Create figure
137
- fig, mpl_axes = plt.subplots(
138
- nrows,
139
- ncols,
140
- figsize=record.figsize,
141
- dpi=record.dpi,
142
- constrained_layout=record.constrained_layout,
143
- )
144
-
145
- # Apply layout if recorded (skip if constrained_layout is used)
146
- if record.layout is not None and not record.constrained_layout:
147
- fig.subplots_adjust(**record.layout)
148
-
149
- # Ensure axes is 2D array
150
- if nrows == 1 and ncols == 1:
151
- axes_2d = np.array([[mpl_axes]])
152
- else:
153
- axes_2d = np.atleast_2d(mpl_axes)
154
- if nrows == 1:
155
- axes_2d = axes_2d.reshape(1, -1)
156
- elif ncols == 1:
157
- axes_2d = axes_2d.reshape(-1, 1)
158
-
159
- # Apply style BEFORE replaying calls (to match original order:
160
- # style is applied during subplots(), then user creates plots/decorations)
161
- if record.style is not None:
162
- from .styles import apply_style_mm
163
-
164
- for row in range(nrows):
165
- for col in range(ncols):
166
- apply_style_mm(axes_2d[row, col], record.style)
167
-
168
- # Result cache for resolving references (e.g., clabel needs ContourSet from contour)
169
- result_cache: Dict[str, Any] = {}
170
-
171
- # Replay calls on each axes
172
- for ax_key, ax_record in record.axes.items():
173
- parts = ax_key.split("_")
174
- if len(parts) >= 3:
175
- row, col = int(parts[1]), int(parts[2])
176
- else:
177
- row, col = 0, 0
178
-
179
- ax = axes_2d[row, col]
180
-
181
- # Replay plotting calls
182
- for call in ax_record.calls:
183
- if calls is not None and call.id not in calls:
184
- continue
185
- result = _replay_call(ax, call, result_cache)
186
- if result is not None:
187
- result_cache[call.id] = result
188
-
189
- # Replay decorations
190
- if not skip_decorations:
191
- for call in ax_record.decorations:
192
- if calls is not None and call.id not in calls:
193
- continue
194
- result = _replay_call(ax, call, result_cache)
195
- if result is not None:
196
- result_cache[call.id] = result
197
-
198
- # Finalize tick configuration (avoids categorical axis interference)
199
- from .styles._style_applier import finalize_ticks
200
-
201
- for row in range(nrows):
202
- for col in range(ncols):
203
- finalize_ticks(axes_2d[row, col])
204
-
205
- # Apply figure-level labels if recorded
206
- if record.suptitle is not None:
207
- text = record.suptitle.get("text", "")
208
- kwargs = record.suptitle.get("kwargs", {}).copy()
209
- # Only add y=1.02 if not using constrained_layout (which handles positioning)
210
- if "y" not in kwargs and not record.constrained_layout:
211
- kwargs["y"] = 1.02
212
- fig.suptitle(text, **kwargs)
213
-
214
- if record.supxlabel is not None:
215
- text = record.supxlabel.get("text", "")
216
- kwargs = record.supxlabel.get("kwargs", {})
217
- fig.supxlabel(text, **kwargs)
218
-
219
- if record.supylabel is not None:
220
- text = record.supylabel.get("text", "")
221
- kwargs = record.supylabel.get("kwargs", {})
222
- fig.supylabel(text, **kwargs)
223
-
224
- # Wrap in Recording types (same as subplots() returns)
225
- recorder = Recorder()
226
- recorder._figure_record = record
227
-
228
- # Wrap axes in RecordingAxes
229
- wrapped_axes = np.empty((nrows, ncols), dtype=object)
230
- for i in range(nrows):
231
- for j in range(ncols):
232
- wrapped_axes[i, j] = RecordingAxes(axes_2d[i, j], recorder, position=(i, j))
233
-
234
- # Create RecordingFigure
235
- wrapped_fig = RecordingFigure(fig, recorder, wrapped_axes.tolist())
236
-
237
- # Return in appropriate format (matching subplots() behavior)
238
- if nrows == 1 and ncols == 1:
239
- return wrapped_fig, wrapped_axes[0, 0]
240
- elif nrows == 1:
241
- return wrapped_fig, np.array(wrapped_axes[0], dtype=object)
242
- elif ncols == 1:
243
- return wrapped_fig, np.array(wrapped_axes[:, 0], dtype=object)
244
- else:
245
- return wrapped_fig, wrapped_axes
246
-
247
-
248
- def _replay_call(
249
- ax: Axes, call: CallRecord, result_cache: Optional[Dict[str, Any]] = None
250
- ) -> Any:
251
- """Replay a single call on an axes.
252
-
253
- Parameters
254
- ----------
255
- ax : Axes
256
- The matplotlib axes.
257
- call : CallRecord
258
- The call to replay.
259
- result_cache : dict, optional
260
- Cache mapping call_id -> result for resolving references.
261
-
262
- Returns
263
- -------
264
- Any
265
- Result of the matplotlib call.
266
- """
267
- if result_cache is None:
268
- result_cache = {}
269
-
270
- method_name = call.function
271
-
272
- # Check if it's a seaborn call
273
- if method_name.startswith("sns."):
274
- return _replay_seaborn_call(ax, call)
275
-
276
- # Handle violinplot with inner option specially
277
- if method_name == "violinplot":
278
- return _replay_violinplot_call(ax, call)
279
-
280
- # Handle joyplot specially (custom method)
281
- if method_name == "joyplot":
282
- return _replay_joyplot_call(ax, call)
283
-
284
- # Handle swarmplot specially (custom method)
285
- if method_name == "swarmplot":
286
- return _replay_swarmplot_call(ax, call)
287
-
288
- method = getattr(ax, method_name, None)
289
-
290
- if method is None:
291
- # Method not found, skip
292
- return None
293
-
294
- # Reconstruct args
295
- args = []
296
- for arg_data in call.args:
297
- value = _reconstruct_value(arg_data, result_cache)
298
- args.append(value)
299
-
300
- # Get kwargs and reconstruct arrays
301
- kwargs = _reconstruct_kwargs(call.kwargs)
302
-
303
- # Handle special transform markers
304
- if "transform" in kwargs:
305
- transform_val = kwargs["transform"]
306
- if transform_val == "axes":
307
- kwargs["transform"] = ax.transAxes
308
- elif transform_val == "data":
309
- kwargs["transform"] = ax.transData
310
- elif transform_val == "figure":
311
- kwargs["transform"] = ax.figure.transFigure
312
- # If it's already a Transform object or something else, leave it
313
-
314
- # Call the method
315
- try:
316
- return method(*args, **kwargs)
317
- except Exception as e:
318
- # Log warning but continue
319
- import warnings
320
-
321
- warnings.warn(f"Failed to replay {method_name}: {e}")
322
- return None
323
-
324
-
325
- def _reconstruct_kwargs(kwargs: Dict[str, Any]) -> Dict[str, Any]:
326
- """Reconstruct kwargs, converting 2D lists back to numpy arrays.
327
-
328
- Parameters
329
- ----------
330
- kwargs : dict
331
- Raw kwargs from call record.
332
-
333
- Returns
334
- -------
335
- dict
336
- Kwargs with arrays properly reconstructed.
337
- """
338
- result = {}
339
- for key, value in kwargs.items():
340
- if isinstance(value, list) and len(value) > 0:
341
- # Check if it's a 2D list (list of lists) - should be numpy array
342
- if isinstance(value[0], list):
343
- result[key] = np.array(value)
344
- else:
345
- # 1D list - could be array or just list, try to preserve
346
- result[key] = value
347
- else:
348
- result[key] = value
349
- return result
350
-
351
-
352
- def _replay_violinplot_call(ax: Axes, call: CallRecord) -> Any:
353
- """Replay a violinplot call with inner option support.
354
-
355
- Parameters
356
- ----------
357
- ax : Axes
358
- The matplotlib axes.
359
- call : CallRecord
360
- The violinplot call to replay.
361
-
362
- Returns
363
- -------
364
- Any
365
- Result of the violinplot call.
366
- """
367
- # Reconstruct args
368
- args = []
369
- for arg_data in call.args:
370
- value = _reconstruct_value(arg_data)
371
- args.append(value)
372
-
373
- # Get kwargs and reconstruct arrays
374
- kwargs = _reconstruct_kwargs(call.kwargs)
375
-
376
- # Extract inner option (not a matplotlib kwarg)
377
- inner = kwargs.pop("inner", "box")
378
-
379
- # Get display options
380
- showmeans = kwargs.pop("showmeans", False)
381
- showmedians = kwargs.pop("showmedians", True)
382
- showextrema = kwargs.pop("showextrema", False)
383
-
384
- # When using inner box/swarm, suppress default median/extrema lines
385
- if inner in ("box", "swarm"):
386
- showmedians = False
387
- showextrema = False
388
-
389
- # Call matplotlib's violinplot
390
- try:
391
- result = ax.violinplot(
392
- *args,
393
- showmeans=showmeans,
394
- showmedians=showmedians,
395
- showextrema=showextrema,
396
- **kwargs,
397
- )
398
-
399
- # Get style settings for inner display
400
- from .styles import get_style
401
-
402
- style = get_style()
403
- violin_style = style.get("violinplot", {}) if style else {}
404
-
405
- # Apply alpha from style to violin bodies
406
- alpha = violin_style.get("alpha", 0.7)
407
- if "bodies" in result:
408
- for body in result["bodies"]:
409
- body.set_alpha(alpha)
410
-
411
- # Determine positions
412
- dataset = args[0] if args else []
413
- positions = kwargs.get("positions")
414
- if positions is None:
415
- positions = list(range(1, len(dataset) + 1))
416
-
417
- # Overlay inner elements based on inner type
418
- if inner == "box":
419
- _add_violin_inner_box(ax, dataset, positions, violin_style)
420
- elif inner == "swarm":
421
- _add_violin_inner_swarm(ax, dataset, positions, violin_style)
422
- elif inner == "stick":
423
- _add_violin_inner_stick(ax, dataset, positions, violin_style)
424
- elif inner == "point":
425
- _add_violin_inner_point(ax, dataset, positions, violin_style)
426
-
427
- return result
428
- except Exception as e:
429
- import warnings
430
-
431
- warnings.warn(f"Failed to replay violinplot: {e}")
432
- return None
433
-
434
-
435
- def _add_violin_inner_box(ax: Axes, dataset, positions, style: Dict[str, Any]) -> None:
436
- """Add box plot inside violin for reproduction."""
437
- from .styles._style_applier import mm_to_pt
438
-
439
- whisker_lw = mm_to_pt(style.get("whisker_mm", 0.2))
440
- median_size = mm_to_pt(style.get("median_mm", 0.8))
441
-
442
- for data, pos in zip(dataset, positions):
443
- data = np.asarray(data)
444
- q1, median, q3 = np.percentile(data, [25, 50, 75])
445
- iqr = q3 - q1
446
- whisker_low = max(data.min(), q1 - 1.5 * iqr)
447
- whisker_high = min(data.max(), q3 + 1.5 * iqr)
448
-
449
- # Draw box (Q1 to Q3)
450
- ax.vlines(pos, q1, q3, colors="black", linewidths=whisker_lw, zorder=3)
451
- # Draw whiskers
452
- ax.vlines(
453
- pos, whisker_low, q1, colors="black", linewidths=whisker_lw * 0.5, zorder=3
454
- )
455
- ax.vlines(
456
- pos, q3, whisker_high, colors="black", linewidths=whisker_lw * 0.5, zorder=3
457
- )
458
- # Draw median as a white dot with black edge
459
- ax.scatter(
460
- [pos],
461
- [median],
462
- s=median_size**2,
463
- c="white",
464
- edgecolors="black",
465
- linewidths=whisker_lw,
466
- zorder=4,
467
- )
468
-
469
-
470
- def _add_violin_inner_swarm(
471
- ax: Axes, dataset, positions, style: Dict[str, Any]
472
- ) -> None:
473
- """Add swarm points inside violin for reproduction."""
474
- from .styles._style_applier import mm_to_pt
475
-
476
- point_size = mm_to_pt(style.get("median_mm", 0.8))
477
-
478
- for data, pos in zip(dataset, positions):
479
- data = np.asarray(data)
480
- n = len(data)
481
- jitter = np.random.default_rng(42).uniform(-0.15, 0.15, n)
482
- x_positions = pos + jitter
483
- ax.scatter(x_positions, data, s=point_size**2, c="black", alpha=0.5, zorder=3)
484
-
485
-
486
- def _add_violin_inner_stick(
487
- ax: Axes, dataset, positions, style: Dict[str, Any]
488
- ) -> None:
489
- """Add stick markers inside violin for reproduction."""
490
- from .styles._style_applier import mm_to_pt
491
-
492
- lw = mm_to_pt(style.get("whisker_mm", 0.2))
493
-
494
- for data, pos in zip(dataset, positions):
495
- data = np.asarray(data)
496
- for val in data:
497
- ax.hlines(
498
- val,
499
- pos - 0.05,
500
- pos + 0.05,
501
- colors="black",
502
- linewidths=lw * 0.5,
503
- alpha=0.3,
504
- zorder=3,
505
- )
506
-
507
-
508
- def _add_violin_inner_point(
509
- ax: Axes, dataset, positions, style: Dict[str, Any]
510
- ) -> None:
511
- """Add point markers inside violin for reproduction."""
512
- from .styles._style_applier import mm_to_pt
513
-
514
- point_size = mm_to_pt(style.get("median_mm", 0.8)) * 0.5
515
-
516
- for data, pos in zip(dataset, positions):
517
- data = np.asarray(data)
518
- x_positions = np.full_like(data, pos)
519
- ax.scatter(x_positions, data, s=point_size**2, c="black", alpha=0.3, zorder=3)
520
-
521
-
522
- def _replay_seaborn_call(ax: Axes, call: CallRecord) -> Any:
523
- """Replay a seaborn call on an axes.
524
-
525
- Parameters
526
- ----------
527
- ax : Axes
528
- The matplotlib axes.
529
- call : CallRecord
530
- The seaborn call to replay.
531
-
532
- Returns
533
- -------
534
- Any
535
- Result of the seaborn call.
536
- """
537
- try:
538
- import pandas as pd
539
- import seaborn as sns
540
- except ImportError:
541
- import warnings
542
-
543
- warnings.warn("seaborn/pandas required to replay seaborn calls")
544
- return None
545
-
546
- # Get the seaborn function name (remove "sns." prefix)
547
- func_name = call.function[4:] # Remove "sns."
548
- func = getattr(sns, func_name, None)
549
-
550
- if func is None:
551
- import warnings
552
-
553
- warnings.warn(f"Seaborn function {func_name} not found")
554
- return None
555
-
556
- # Reconstruct data from args
557
- # Args contain column data with "param" field indicating the parameter name
558
- data_dict = {}
559
- param_mapping = {} # Maps param name to column name
560
-
561
- for arg_data in call.args:
562
- param = arg_data.get("param")
563
- name = arg_data.get("name")
564
- value = _reconstruct_value(arg_data)
565
-
566
- if param is not None:
567
- # This is a DataFrame column
568
- col_name = name if name else param
569
- data_dict[col_name] = value
570
- param_mapping[param] = col_name
571
-
572
- # Build kwargs
573
- kwargs = call.kwargs.copy()
574
-
575
- # Remove internal keys
576
- internal_keys = [k for k in kwargs.keys() if k.startswith("_")]
577
- for key in internal_keys:
578
- kwargs.pop(key, None)
579
-
580
- # If we have data columns, create a DataFrame
581
- if data_dict:
582
- df = pd.DataFrame(data_dict)
583
- kwargs["data"] = df
584
-
585
- # Update column name references in kwargs
586
- for param, col_name in param_mapping.items():
587
- if param in ["x", "y", "hue", "size", "style", "row", "col"]:
588
- kwargs[param] = col_name
589
-
590
- # Add the axes
591
- kwargs["ax"] = ax
592
-
593
- # Convert certain list parameters back to tuples (YAML serializes tuples as lists)
594
- # 'sizes' in seaborn expects a tuple (min, max) for range, not a list
595
- if "sizes" in kwargs and isinstance(kwargs["sizes"], list):
596
- kwargs["sizes"] = tuple(kwargs["sizes"])
597
-
598
- # Call the seaborn function
599
- try:
600
- return func(**kwargs)
601
- except Exception as e:
602
- import warnings
603
-
604
- warnings.warn(f"Failed to replay sns.{func_name}: {e}")
605
- return None
606
-
607
-
608
- def _replay_joyplot_call(ax: Axes, call: CallRecord) -> Any:
609
- """Replay a joyplot call on an axes.
610
-
611
- Parameters
612
- ----------
613
- ax : Axes
614
- The matplotlib axes.
615
- call : CallRecord
616
- The joyplot call to replay.
617
-
618
- Returns
619
- -------
620
- Any
621
- Result of the joyplot call.
622
- """
623
- from scipy import stats
624
-
625
- # Reconstruct args
626
- arrays = []
627
- for arg_data in call.args:
628
- value = _reconstruct_value(arg_data)
629
- if isinstance(value, list):
630
- # Could be a list of arrays
631
- arrays = [np.asarray(arr) for arr in value]
632
- else:
633
- arrays.append(np.asarray(value))
634
-
635
- if not arrays:
636
- return None
637
-
638
- # Get kwargs
639
- kwargs = _reconstruct_kwargs(call.kwargs)
640
- overlap = kwargs.get("overlap", 0.5)
641
- fill_alpha = kwargs.get("fill_alpha", 0.7)
642
- line_alpha = kwargs.get("line_alpha", 1.0)
643
- labels = kwargs.get("labels")
644
-
645
- n_ridges = len(arrays)
646
-
647
- # Get colors from style
648
- from .styles import get_style
649
-
650
- style = get_style()
651
- if style and "colors" in style and "palette" in style.colors:
652
- palette = list(style.colors.palette)
653
- colors = []
654
- for c in palette:
655
- if isinstance(c, (list, tuple)) and len(c) >= 3:
656
- if all(v <= 1.0 for v in c):
657
- colors.append(tuple(c))
658
- else:
659
- colors.append(tuple(v / 255.0 for v in c))
660
- else:
661
- colors.append(c)
662
- else:
663
- colors = [c["color"] for c in plt.rcParams["axes.prop_cycle"]]
664
-
665
- # Calculate global x range
666
- all_data = np.concatenate([np.asarray(arr) for arr in arrays])
667
- x_min, x_max = np.min(all_data), np.max(all_data)
668
- x_range = x_max - x_min
669
- x_padding = x_range * 0.1
670
- x = np.linspace(x_min - x_padding, x_max + x_padding, 200)
671
-
672
- # Calculate KDEs and find max density for scaling
673
- kdes = []
674
- max_density = 0
675
- for arr in arrays:
676
- arr = np.asarray(arr)
677
- if len(arr) > 1:
678
- kde = stats.gaussian_kde(arr)
679
- density = kde(x)
680
- kdes.append(density)
681
- max_density = max(max_density, np.max(density))
682
- else:
683
- kdes.append(np.zeros_like(x))
684
-
685
- # Scale factor for ridge height
686
- ridge_height = 1.0 / (1.0 - overlap * 0.5) if overlap < 1 else 2.0
687
-
688
- # Get line width from style
689
- from ._utils._units import mm_to_pt
690
-
691
- lw = mm_to_pt(0.2) # Default
692
- if style and "lines" in style:
693
- lw = mm_to_pt(style.lines.get("trace_mm", 0.2))
694
-
695
- # Plot each ridge from back to front
696
- for i in range(n_ridges - 1, -1, -1):
697
- color = colors[i % len(colors)]
698
- baseline = i * (1.0 - overlap)
699
-
700
- # Scale density to fit nicely
701
- scaled_density = (
702
- kdes[i] / max_density * ridge_height if max_density > 0 else kdes[i]
703
- )
704
-
705
- # Fill
706
- ax.fill_between(
707
- x,
708
- baseline,
709
- baseline + scaled_density,
710
- facecolor=color,
711
- edgecolor="none",
712
- alpha=fill_alpha,
713
- )
714
- # Line on top
715
- ax.plot(
716
- x, baseline + scaled_density, color=color, alpha=line_alpha, linewidth=lw
717
- )
718
-
719
- # Set y limits
720
- ax.set_ylim(-0.1, n_ridges * (1.0 - overlap) + ridge_height)
721
-
722
- # Set y-axis labels if provided
723
- if labels:
724
- y_positions = [(i * (1.0 - overlap)) + 0.3 for i in range(n_ridges)]
725
- ax.set_yticks(y_positions)
726
- ax.set_yticklabels(labels)
727
- else:
728
- ax.set_yticks([])
729
-
730
- return ax
731
-
732
-
733
- def _replay_swarmplot_call(ax: Axes, call: CallRecord) -> Any:
734
- """Replay a swarmplot call on an axes.
735
-
736
- Parameters
737
- ----------
738
- ax : Axes
739
- The matplotlib axes.
740
- call : CallRecord
741
- The swarmplot call to replay.
742
-
743
- Returns
744
- -------
745
- list
746
- List of PathCollection objects.
747
- """
748
- # Reconstruct args
749
- data = []
750
- for arg_data in call.args:
751
- value = _reconstruct_value(arg_data)
752
- if isinstance(value, list):
753
- # Could be a list of arrays
754
- data = [np.asarray(arr) for arr in value]
755
- else:
756
- data.append(np.asarray(value))
757
-
758
- if not data:
759
- return []
760
-
761
- # Get kwargs
762
- kwargs = _reconstruct_kwargs(call.kwargs)
763
- positions = kwargs.get("positions")
764
- size = kwargs.get("size", 0.8)
765
- alpha = kwargs.get("alpha", 0.7)
766
- jitter = kwargs.get("jitter", 0.3)
767
-
768
- if positions is None:
769
- positions = list(range(1, len(data) + 1))
770
-
771
- # Get style
772
- from ._utils._units import mm_to_pt
773
- from .styles import get_style
774
-
775
- style = get_style()
776
- size_pt = mm_to_pt(size) ** 2 # matplotlib uses area
777
-
778
- # Get colors
779
- if style and "colors" in style and "palette" in style.colors:
780
- palette = list(style.colors.palette)
781
- colors = []
782
- for c in palette:
783
- if isinstance(c, (list, tuple)) and len(c) >= 3:
784
- if all(v <= 1.0 for v in c):
785
- colors.append(tuple(c))
786
- else:
787
- colors.append(tuple(v / 255.0 for v in c))
788
- else:
789
- colors.append(c)
790
- else:
791
- colors = [c["color"] for c in plt.rcParams["axes.prop_cycle"]]
792
-
793
- # Random generator for reproducible jitter
794
- rng = np.random.default_rng(42)
795
-
796
- results = []
797
- for i, (arr, pos) in enumerate(zip(data, positions)):
798
- arr = np.asarray(arr)
799
-
800
- # Create jittered x positions using simplified beeswarm
801
- x_offsets = _beeswarm_positions(arr, jitter, rng)
802
- x_positions = pos + x_offsets
803
-
804
- c = colors[i % len(colors)]
805
- result = ax.scatter(
806
- x_positions,
807
- arr,
808
- s=size_pt,
809
- c=[c],
810
- alpha=alpha,
811
- )
812
- results.append(result)
813
-
814
- return results
815
-
816
-
817
- def _beeswarm_positions(
818
- data: np.ndarray,
819
- width: float,
820
- rng: np.random.Generator,
821
- ) -> np.ndarray:
822
- """Calculate beeswarm-style x positions to minimize overlap.
823
-
824
- Parameters
825
- ----------
826
- data : array
827
- Y values of points.
828
- width : float
829
- Maximum jitter width.
830
- rng : Generator
831
- Random number generator.
832
-
833
- Returns
834
- -------
835
- array
836
- X offsets for each point.
837
- """
838
- n = len(data)
839
- if n == 0:
840
- return np.array([])
841
-
842
- # Sort data and get order
843
- order = np.argsort(data)
844
- sorted_data = data[order]
845
-
846
- # Group nearby points and offset them
847
- x_offsets = np.zeros(n)
848
-
849
- # Simple approach: bin by quantiles and spread within each bin
850
- n_bins = max(1, int(np.sqrt(n)))
851
- bin_edges = np.percentile(sorted_data, np.linspace(0, 100, n_bins + 1))
852
-
853
- for i in range(n_bins):
854
- mask = (sorted_data >= bin_edges[i]) & (sorted_data <= bin_edges[i + 1])
855
- n_in_bin = mask.sum()
856
- if n_in_bin > 0:
857
- # Spread points evenly within bin width
858
- offsets = np.linspace(-width / 2, width / 2, n_in_bin)
859
- # Add small random noise
860
- offsets += rng.uniform(-width * 0.1, width * 0.1, n_in_bin)
861
- x_offsets[mask] = offsets
862
-
863
- # Restore original order
864
- result = np.zeros(n)
865
- result[order] = x_offsets
866
- return result
867
-
868
-
869
- def _reconstruct_value(
870
- arg_data: Dict[str, Any], result_cache: Optional[Dict[str, Any]] = None
871
- ) -> Any:
872
- """Reconstruct a value from serialized arg data.
873
-
874
- Parameters
875
- ----------
876
- arg_data : dict
877
- Serialized argument data.
878
- result_cache : dict, optional
879
- Cache mapping call_id -> result for resolving references.
880
-
881
- Returns
882
- -------
883
- Any
884
- Reconstructed value.
885
- """
886
- if result_cache is None:
887
- result_cache = {}
888
-
889
- # Check if we have a pre-loaded array
890
- if "_loaded_array" in arg_data:
891
- return arg_data["_loaded_array"]
892
-
893
- data = arg_data.get("data")
894
-
895
- # Check if it's a reference to another call's result (e.g., ContourSet for clabel)
896
- if isinstance(data, dict) and "__ref__" in data:
897
- ref_id = data["__ref__"]
898
- if ref_id in result_cache:
899
- return result_cache[ref_id]
900
- else:
901
- import warnings
902
-
903
- warnings.warn(f"Could not resolve reference to {ref_id}")
904
- return None
905
-
906
- # Check if it's a list of arrays (e.g., boxplot, violinplot)
907
- if arg_data.get("_is_array_list") and isinstance(data, list):
908
- dtype = arg_data.get("dtype")
909
- # Convert each inner list to numpy array
910
- return [
911
- np.array(arr_data, dtype=dtype if isinstance(dtype, str) else None)
912
- for arr_data in data
913
- ]
914
-
915
- # If data is a list, convert to numpy array
916
- if isinstance(data, list):
917
- dtype = arg_data.get("dtype")
918
- try:
919
- return np.array(data, dtype=dtype if dtype else None)
920
- except (TypeError, ValueError):
921
- return np.array(data)
922
-
923
- return data
924
-
925
-
926
- def get_recipe_info(path: Union[str, Path]) -> Dict[str, Any]:
927
- """Get information about a recipe without reproducing.
928
-
929
- Parameters
930
- ----------
931
- path : str or Path
932
- Path to .yaml recipe file.
933
-
934
- Returns
935
- -------
936
- dict
937
- Recipe information including:
938
- - id: Figure ID
939
- - created: Creation timestamp
940
- - matplotlib_version: Version used
941
- - figsize: Figure size
942
- - n_axes: Number of axes
943
- - calls: List of call IDs
944
- """
945
- record = load_recipe(path)
946
-
947
- all_calls = []
948
- for ax_record in record.axes.values():
949
- for call in ax_record.calls:
950
- all_calls.append(
951
- {
952
- "id": call.id,
953
- "function": call.function,
954
- "n_args": len(call.args),
955
- "kwargs": list(call.kwargs.keys()),
956
- }
957
- )
958
- for call in ax_record.decorations:
959
- all_calls.append(
960
- {
961
- "id": call.id,
962
- "function": call.function,
963
- "type": "decoration",
964
- }
965
- )
966
-
967
- return {
968
- "id": record.id,
969
- "created": record.created,
970
- "matplotlib_version": record.matplotlib_version,
971
- "figsize": record.figsize,
972
- "dpi": record.dpi,
973
- "n_axes": len(record.axes),
974
- "calls": all_calls,
975
- }