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.
- figrecipe/__init__.py +106 -973
- figrecipe/_api/__init__.py +48 -0
- figrecipe/_api/_extract.py +108 -0
- figrecipe/_api/_notebook.py +61 -0
- figrecipe/_api/_panel.py +46 -0
- figrecipe/_api/_save.py +191 -0
- figrecipe/_api/_seaborn_proxy.py +34 -0
- figrecipe/_api/_style_manager.py +153 -0
- figrecipe/_api/_subplots.py +333 -0
- figrecipe/_api/_validate.py +82 -0
- figrecipe/_dev/__init__.py +2 -93
- figrecipe/_dev/_plotters.py +76 -0
- figrecipe/_dev/_run_demos.py +56 -0
- figrecipe/_dev/demo_plotters/__init__.py +35 -166
- figrecipe/_dev/demo_plotters/_categories.py +81 -0
- figrecipe/_dev/demo_plotters/_figure_creators.py +119 -0
- figrecipe/_dev/demo_plotters/_helpers.py +31 -0
- figrecipe/_dev/demo_plotters/_registry.py +50 -0
- figrecipe/_dev/demo_plotters/bar_categorical/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/contour_surface/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/distribution/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/image_matrix/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/line_curve/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/{plot_plot.py → line_curve/plot_plot.py} +3 -2
- figrecipe/_dev/demo_plotters/scatter_points/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/special/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/{plot_pie.py → special/plot_pie.py} +5 -1
- figrecipe/_dev/demo_plotters/spectral_signal/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/vector_flow/__init__.py +4 -0
- figrecipe/_editor/__init__.py +57 -9
- figrecipe/_editor/_bbox/__init__.py +43 -0
- figrecipe/_editor/_bbox/_collections.py +177 -0
- figrecipe/_editor/_bbox/_elements.py +159 -0
- figrecipe/_editor/_bbox/_extract.py +256 -0
- figrecipe/_editor/_bbox/_extract_axes.py +370 -0
- figrecipe/_editor/_bbox/_extract_text.py +342 -0
- figrecipe/_editor/_bbox/_lines.py +173 -0
- figrecipe/_editor/_bbox/_transforms.py +146 -0
- figrecipe/_editor/_flask_app.py +68 -1039
- figrecipe/_editor/_helpers.py +242 -0
- figrecipe/_editor/_hitmap/__init__.py +76 -0
- figrecipe/_editor/_hitmap/_artists/__init__.py +21 -0
- figrecipe/_editor/_hitmap/_artists/_collections.py +345 -0
- figrecipe/_editor/_hitmap/_artists/_images.py +68 -0
- figrecipe/_editor/_hitmap/_artists/_lines.py +107 -0
- figrecipe/_editor/_hitmap/_artists/_patches.py +163 -0
- figrecipe/_editor/_hitmap/_artists/_text.py +190 -0
- figrecipe/_editor/_hitmap/_colors.py +181 -0
- figrecipe/_editor/_hitmap/_detect.py +137 -0
- figrecipe/_editor/_hitmap/_restore.py +154 -0
- figrecipe/_editor/_hitmap_main.py +182 -0
- figrecipe/_editor/_preferences.py +135 -0
- figrecipe/_editor/_render_overrides.py +480 -0
- figrecipe/_editor/_renderer.py +35 -185
- figrecipe/_editor/_routes_axis.py +453 -0
- figrecipe/_editor/_routes_core.py +284 -0
- figrecipe/_editor/_routes_element.py +317 -0
- figrecipe/_editor/_routes_style.py +223 -0
- figrecipe/_editor/_templates/__init__.py +78 -1
- figrecipe/_editor/_templates/_html.py +109 -13
- figrecipe/_editor/_templates/_scripts/__init__.py +120 -0
- figrecipe/_editor/_templates/_scripts/_api.py +228 -0
- figrecipe/_editor/_templates/_scripts/_colors.py +485 -0
- figrecipe/_editor/_templates/_scripts/_core.py +436 -0
- figrecipe/_editor/_templates/_scripts/_debug_snapshot.py +186 -0
- figrecipe/_editor/_templates/_scripts/_element_editor.py +310 -0
- figrecipe/_editor/_templates/_scripts/_files.py +195 -0
- figrecipe/_editor/_templates/_scripts/_hitmap.py +509 -0
- figrecipe/_editor/_templates/_scripts/_inspector.py +315 -0
- figrecipe/_editor/_templates/_scripts/_labels.py +464 -0
- figrecipe/_editor/_templates/_scripts/_legend_drag.py +265 -0
- figrecipe/_editor/_templates/_scripts/_modals.py +226 -0
- figrecipe/_editor/_templates/_scripts/_overlays.py +292 -0
- figrecipe/_editor/_templates/_scripts/_panel_drag.py +334 -0
- figrecipe/_editor/_templates/_scripts/_panel_position.py +279 -0
- figrecipe/_editor/_templates/_scripts/_selection.py +237 -0
- figrecipe/_editor/_templates/_scripts/_tabs.py +89 -0
- figrecipe/_editor/_templates/_scripts/_view_mode.py +107 -0
- figrecipe/_editor/_templates/_scripts/_zoom.py +179 -0
- figrecipe/_editor/_templates/_styles/__init__.py +69 -0
- figrecipe/_editor/_templates/_styles/_base.py +64 -0
- figrecipe/_editor/_templates/_styles/_buttons.py +206 -0
- figrecipe/_editor/_templates/_styles/_color_input.py +123 -0
- figrecipe/_editor/_templates/_styles/_controls.py +265 -0
- figrecipe/_editor/_templates/_styles/_dynamic_props.py +144 -0
- figrecipe/_editor/_templates/_styles/_forms.py +126 -0
- figrecipe/_editor/_templates/_styles/_hitmap.py +184 -0
- figrecipe/_editor/_templates/_styles/_inspector.py +90 -0
- figrecipe/_editor/_templates/_styles/_labels.py +118 -0
- figrecipe/_editor/_templates/_styles/_modals.py +98 -0
- figrecipe/_editor/_templates/_styles/_overlays.py +130 -0
- figrecipe/_editor/_templates/_styles/_preview.py +225 -0
- figrecipe/_editor/_templates/_styles/_selection.py +73 -0
- figrecipe/_params/_DECORATION_METHODS.py +6 -0
- figrecipe/_recorder.py +35 -106
- figrecipe/_recorder_utils.py +124 -0
- figrecipe/_reproducer/__init__.py +18 -0
- figrecipe/_reproducer/_core.py +498 -0
- figrecipe/_reproducer/_custom_plots.py +279 -0
- figrecipe/_reproducer/_seaborn.py +100 -0
- figrecipe/_reproducer/_violin.py +186 -0
- figrecipe/_signatures/_kwargs.py +273 -0
- figrecipe/_signatures/_loader.py +21 -423
- figrecipe/_signatures/_parsing.py +147 -0
- figrecipe/_wrappers/_axes.py +119 -910
- figrecipe/_wrappers/_axes_helpers.py +136 -0
- figrecipe/_wrappers/_axes_plots.py +418 -0
- figrecipe/_wrappers/_axes_seaborn.py +157 -0
- figrecipe/_wrappers/_figure.py +162 -0
- figrecipe/_wrappers/_panel_labels.py +127 -0
- figrecipe/_wrappers/_plot_helpers.py +143 -0
- figrecipe/_wrappers/_violin_helpers.py +180 -0
- figrecipe/styles/__init__.py +8 -6
- figrecipe/styles/_dotdict.py +72 -0
- figrecipe/styles/_finalize.py +134 -0
- figrecipe/styles/_fonts.py +77 -0
- figrecipe/styles/_kwargs_converter.py +178 -0
- figrecipe/styles/_plot_styles.py +209 -0
- figrecipe/styles/_style_applier.py +32 -478
- figrecipe/styles/_style_loader.py +16 -192
- figrecipe/styles/_themes.py +151 -0
- figrecipe/styles/presets/MATPLOTLIB.yaml +2 -1
- figrecipe/styles/presets/SCITEX.yaml +29 -24
- {figrecipe-0.6.0.dist-info → figrecipe-0.7.4.dist-info}/METADATA +37 -2
- figrecipe-0.7.4.dist-info/RECORD +188 -0
- figrecipe/_editor/_bbox.py +0 -978
- figrecipe/_editor/_hitmap.py +0 -937
- figrecipe/_editor/_templates/_scripts.py +0 -2778
- figrecipe/_editor/_templates/_styles.py +0 -1326
- figrecipe/_reproducer.py +0 -975
- figrecipe-0.6.0.dist-info/RECORD +0 -90
- /figrecipe/_dev/demo_plotters/{plot_bar.py → bar_categorical/plot_bar.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_barh.py → bar_categorical/plot_barh.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_contour.py → contour_surface/plot_contour.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_contourf.py → contour_surface/plot_contourf.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_tricontour.py → contour_surface/plot_tricontour.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_tricontourf.py → contour_surface/plot_tricontourf.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_tripcolor.py → contour_surface/plot_tripcolor.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_triplot.py → contour_surface/plot_triplot.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_boxplot.py → distribution/plot_boxplot.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_ecdf.py → distribution/plot_ecdf.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_hist.py → distribution/plot_hist.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_hist2d.py → distribution/plot_hist2d.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_violinplot.py → distribution/plot_violinplot.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_hexbin.py → image_matrix/plot_hexbin.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_imshow.py → image_matrix/plot_imshow.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_matshow.py → image_matrix/plot_matshow.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_pcolor.py → image_matrix/plot_pcolor.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_pcolormesh.py → image_matrix/plot_pcolormesh.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_spy.py → image_matrix/plot_spy.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_errorbar.py → line_curve/plot_errorbar.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_fill.py → line_curve/plot_fill.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_fill_between.py → line_curve/plot_fill_between.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_fill_betweenx.py → line_curve/plot_fill_betweenx.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_stackplot.py → line_curve/plot_stackplot.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_stairs.py → line_curve/plot_stairs.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_step.py → line_curve/plot_step.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_scatter.py → scatter_points/plot_scatter.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_eventplot.py → special/plot_eventplot.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_loglog.py → special/plot_loglog.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_semilogx.py → special/plot_semilogx.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_semilogy.py → special/plot_semilogy.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_stem.py → special/plot_stem.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_acorr.py → spectral_signal/plot_acorr.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_angle_spectrum.py → spectral_signal/plot_angle_spectrum.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_cohere.py → spectral_signal/plot_cohere.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_csd.py → spectral_signal/plot_csd.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_magnitude_spectrum.py → spectral_signal/plot_magnitude_spectrum.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_phase_spectrum.py → spectral_signal/plot_phase_spectrum.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_psd.py → spectral_signal/plot_psd.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_specgram.py → spectral_signal/plot_specgram.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_xcorr.py → spectral_signal/plot_xcorr.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_barbs.py → vector_flow/plot_barbs.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_quiver.py → vector_flow/plot_quiver.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_streamplot.py → vector_flow/plot_streamplot.py} +0 -0
- {figrecipe-0.6.0.dist-info → figrecipe-0.7.4.dist-info}/WHEEL +0 -0
- {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
|
-
}
|