figrecipe 0.5.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 (189) hide show
  1. figrecipe/__init__.py +220 -819
  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 +29 -0
  12. figrecipe/_dev/_plotters.py +76 -0
  13. figrecipe/_dev/_run_demos.py +56 -0
  14. figrecipe/_dev/demo_plotters/__init__.py +64 -0
  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/bar_categorical/plot_bar.py +25 -0
  21. figrecipe/_dev/demo_plotters/bar_categorical/plot_barh.py +25 -0
  22. figrecipe/_dev/demo_plotters/contour_surface/__init__.py +4 -0
  23. figrecipe/_dev/demo_plotters/contour_surface/plot_contour.py +30 -0
  24. figrecipe/_dev/demo_plotters/contour_surface/plot_contourf.py +29 -0
  25. figrecipe/_dev/demo_plotters/contour_surface/plot_tricontour.py +28 -0
  26. figrecipe/_dev/demo_plotters/contour_surface/plot_tricontourf.py +28 -0
  27. figrecipe/_dev/demo_plotters/contour_surface/plot_tripcolor.py +29 -0
  28. figrecipe/_dev/demo_plotters/contour_surface/plot_triplot.py +25 -0
  29. figrecipe/_dev/demo_plotters/distribution/__init__.py +4 -0
  30. figrecipe/_dev/demo_plotters/distribution/plot_boxplot.py +24 -0
  31. figrecipe/_dev/demo_plotters/distribution/plot_ecdf.py +24 -0
  32. figrecipe/_dev/demo_plotters/distribution/plot_hist.py +24 -0
  33. figrecipe/_dev/demo_plotters/distribution/plot_hist2d.py +25 -0
  34. figrecipe/_dev/demo_plotters/distribution/plot_violinplot.py +25 -0
  35. figrecipe/_dev/demo_plotters/image_matrix/__init__.py +4 -0
  36. figrecipe/_dev/demo_plotters/image_matrix/plot_hexbin.py +25 -0
  37. figrecipe/_dev/demo_plotters/image_matrix/plot_imshow.py +23 -0
  38. figrecipe/_dev/demo_plotters/image_matrix/plot_matshow.py +23 -0
  39. figrecipe/_dev/demo_plotters/image_matrix/plot_pcolor.py +29 -0
  40. figrecipe/_dev/demo_plotters/image_matrix/plot_pcolormesh.py +29 -0
  41. figrecipe/_dev/demo_plotters/image_matrix/plot_spy.py +29 -0
  42. figrecipe/_dev/demo_plotters/line_curve/__init__.py +4 -0
  43. figrecipe/_dev/demo_plotters/line_curve/plot_errorbar.py +28 -0
  44. figrecipe/_dev/demo_plotters/line_curve/plot_fill.py +29 -0
  45. figrecipe/_dev/demo_plotters/line_curve/plot_fill_between.py +30 -0
  46. figrecipe/_dev/demo_plotters/line_curve/plot_fill_betweenx.py +28 -0
  47. figrecipe/_dev/demo_plotters/line_curve/plot_plot.py +28 -0
  48. figrecipe/_dev/demo_plotters/line_curve/plot_stackplot.py +29 -0
  49. figrecipe/_dev/demo_plotters/line_curve/plot_stairs.py +27 -0
  50. figrecipe/_dev/demo_plotters/line_curve/plot_step.py +27 -0
  51. figrecipe/_dev/demo_plotters/scatter_points/__init__.py +4 -0
  52. figrecipe/_dev/demo_plotters/scatter_points/plot_scatter.py +24 -0
  53. figrecipe/_dev/demo_plotters/special/__init__.py +4 -0
  54. figrecipe/_dev/demo_plotters/special/plot_eventplot.py +25 -0
  55. figrecipe/_dev/demo_plotters/special/plot_loglog.py +27 -0
  56. figrecipe/_dev/demo_plotters/special/plot_pie.py +27 -0
  57. figrecipe/_dev/demo_plotters/special/plot_semilogx.py +27 -0
  58. figrecipe/_dev/demo_plotters/special/plot_semilogy.py +27 -0
  59. figrecipe/_dev/demo_plotters/special/plot_stem.py +27 -0
  60. figrecipe/_dev/demo_plotters/spectral_signal/__init__.py +4 -0
  61. figrecipe/_dev/demo_plotters/spectral_signal/plot_acorr.py +24 -0
  62. figrecipe/_dev/demo_plotters/spectral_signal/plot_angle_spectrum.py +28 -0
  63. figrecipe/_dev/demo_plotters/spectral_signal/plot_cohere.py +29 -0
  64. figrecipe/_dev/demo_plotters/spectral_signal/plot_csd.py +29 -0
  65. figrecipe/_dev/demo_plotters/spectral_signal/plot_magnitude_spectrum.py +28 -0
  66. figrecipe/_dev/demo_plotters/spectral_signal/plot_phase_spectrum.py +28 -0
  67. figrecipe/_dev/demo_plotters/spectral_signal/plot_psd.py +29 -0
  68. figrecipe/_dev/demo_plotters/spectral_signal/plot_specgram.py +30 -0
  69. figrecipe/_dev/demo_plotters/spectral_signal/plot_xcorr.py +25 -0
  70. figrecipe/_dev/demo_plotters/vector_flow/__init__.py +4 -0
  71. figrecipe/_dev/demo_plotters/vector_flow/plot_barbs.py +30 -0
  72. figrecipe/_dev/demo_plotters/vector_flow/plot_quiver.py +30 -0
  73. figrecipe/_dev/demo_plotters/vector_flow/plot_streamplot.py +30 -0
  74. figrecipe/_editor/__init__.py +278 -0
  75. figrecipe/_editor/_bbox/__init__.py +43 -0
  76. figrecipe/_editor/_bbox/_collections.py +177 -0
  77. figrecipe/_editor/_bbox/_elements.py +159 -0
  78. figrecipe/_editor/_bbox/_extract.py +256 -0
  79. figrecipe/_editor/_bbox/_extract_axes.py +370 -0
  80. figrecipe/_editor/_bbox/_extract_text.py +342 -0
  81. figrecipe/_editor/_bbox/_lines.py +173 -0
  82. figrecipe/_editor/_bbox/_transforms.py +146 -0
  83. figrecipe/_editor/_flask_app.py +258 -0
  84. figrecipe/_editor/_helpers.py +242 -0
  85. figrecipe/_editor/_hitmap/__init__.py +76 -0
  86. figrecipe/_editor/_hitmap/_artists/__init__.py +21 -0
  87. figrecipe/_editor/_hitmap/_artists/_collections.py +345 -0
  88. figrecipe/_editor/_hitmap/_artists/_images.py +68 -0
  89. figrecipe/_editor/_hitmap/_artists/_lines.py +107 -0
  90. figrecipe/_editor/_hitmap/_artists/_patches.py +163 -0
  91. figrecipe/_editor/_hitmap/_artists/_text.py +190 -0
  92. figrecipe/_editor/_hitmap/_colors.py +181 -0
  93. figrecipe/_editor/_hitmap/_detect.py +137 -0
  94. figrecipe/_editor/_hitmap/_restore.py +154 -0
  95. figrecipe/_editor/_hitmap_main.py +182 -0
  96. figrecipe/_editor/_overrides.py +318 -0
  97. figrecipe/_editor/_preferences.py +135 -0
  98. figrecipe/_editor/_render_overrides.py +480 -0
  99. figrecipe/_editor/_renderer.py +199 -0
  100. figrecipe/_editor/_routes_axis.py +453 -0
  101. figrecipe/_editor/_routes_core.py +284 -0
  102. figrecipe/_editor/_routes_element.py +317 -0
  103. figrecipe/_editor/_routes_style.py +223 -0
  104. figrecipe/_editor/_templates/__init__.py +152 -0
  105. figrecipe/_editor/_templates/_html.py +502 -0
  106. figrecipe/_editor/_templates/_scripts/__init__.py +120 -0
  107. figrecipe/_editor/_templates/_scripts/_api.py +228 -0
  108. figrecipe/_editor/_templates/_scripts/_colors.py +485 -0
  109. figrecipe/_editor/_templates/_scripts/_core.py +436 -0
  110. figrecipe/_editor/_templates/_scripts/_debug_snapshot.py +186 -0
  111. figrecipe/_editor/_templates/_scripts/_element_editor.py +310 -0
  112. figrecipe/_editor/_templates/_scripts/_files.py +195 -0
  113. figrecipe/_editor/_templates/_scripts/_hitmap.py +509 -0
  114. figrecipe/_editor/_templates/_scripts/_inspector.py +315 -0
  115. figrecipe/_editor/_templates/_scripts/_labels.py +464 -0
  116. figrecipe/_editor/_templates/_scripts/_legend_drag.py +265 -0
  117. figrecipe/_editor/_templates/_scripts/_modals.py +226 -0
  118. figrecipe/_editor/_templates/_scripts/_overlays.py +292 -0
  119. figrecipe/_editor/_templates/_scripts/_panel_drag.py +334 -0
  120. figrecipe/_editor/_templates/_scripts/_panel_position.py +279 -0
  121. figrecipe/_editor/_templates/_scripts/_selection.py +237 -0
  122. figrecipe/_editor/_templates/_scripts/_tabs.py +89 -0
  123. figrecipe/_editor/_templates/_scripts/_view_mode.py +107 -0
  124. figrecipe/_editor/_templates/_scripts/_zoom.py +179 -0
  125. figrecipe/_editor/_templates/_styles/__init__.py +69 -0
  126. figrecipe/_editor/_templates/_styles/_base.py +64 -0
  127. figrecipe/_editor/_templates/_styles/_buttons.py +206 -0
  128. figrecipe/_editor/_templates/_styles/_color_input.py +123 -0
  129. figrecipe/_editor/_templates/_styles/_controls.py +265 -0
  130. figrecipe/_editor/_templates/_styles/_dynamic_props.py +144 -0
  131. figrecipe/_editor/_templates/_styles/_forms.py +126 -0
  132. figrecipe/_editor/_templates/_styles/_hitmap.py +184 -0
  133. figrecipe/_editor/_templates/_styles/_inspector.py +90 -0
  134. figrecipe/_editor/_templates/_styles/_labels.py +118 -0
  135. figrecipe/_editor/_templates/_styles/_modals.py +98 -0
  136. figrecipe/_editor/_templates/_styles/_overlays.py +130 -0
  137. figrecipe/_editor/_templates/_styles/_preview.py +225 -0
  138. figrecipe/_editor/_templates/_styles/_selection.py +73 -0
  139. figrecipe/_params/_DECORATION_METHODS.py +33 -0
  140. figrecipe/_params/_PLOTTING_METHODS.py +58 -0
  141. figrecipe/_params/__init__.py +9 -0
  142. figrecipe/_recorder.py +92 -110
  143. figrecipe/_recorder_utils.py +124 -0
  144. figrecipe/_reproducer/__init__.py +18 -0
  145. figrecipe/_reproducer/_core.py +498 -0
  146. figrecipe/_reproducer/_custom_plots.py +279 -0
  147. figrecipe/_reproducer/_seaborn.py +100 -0
  148. figrecipe/_reproducer/_violin.py +186 -0
  149. figrecipe/_seaborn.py +14 -9
  150. figrecipe/_serializer.py +2 -2
  151. figrecipe/_signatures/README.md +68 -0
  152. figrecipe/_signatures/__init__.py +12 -2
  153. figrecipe/_signatures/_kwargs.py +273 -0
  154. figrecipe/_signatures/_loader.py +114 -57
  155. figrecipe/_signatures/_parsing.py +147 -0
  156. figrecipe/_utils/__init__.py +6 -4
  157. figrecipe/_utils/_crop.py +10 -4
  158. figrecipe/_utils/_image_diff.py +37 -33
  159. figrecipe/_utils/_numpy_io.py +0 -1
  160. figrecipe/_utils/_units.py +11 -3
  161. figrecipe/_validator.py +12 -3
  162. figrecipe/_wrappers/_axes.py +193 -170
  163. figrecipe/_wrappers/_axes_helpers.py +136 -0
  164. figrecipe/_wrappers/_axes_plots.py +418 -0
  165. figrecipe/_wrappers/_axes_seaborn.py +157 -0
  166. figrecipe/_wrappers/_figure.py +277 -18
  167. figrecipe/_wrappers/_panel_labels.py +127 -0
  168. figrecipe/_wrappers/_plot_helpers.py +143 -0
  169. figrecipe/_wrappers/_violin_helpers.py +180 -0
  170. figrecipe/plt.py +0 -1
  171. figrecipe/pyplot.py +2 -1
  172. figrecipe/styles/__init__.py +12 -11
  173. figrecipe/styles/_dotdict.py +72 -0
  174. figrecipe/styles/_finalize.py +134 -0
  175. figrecipe/styles/_fonts.py +77 -0
  176. figrecipe/styles/_kwargs_converter.py +178 -0
  177. figrecipe/styles/_plot_styles.py +209 -0
  178. figrecipe/styles/_style_applier.py +60 -202
  179. figrecipe/styles/_style_loader.py +73 -121
  180. figrecipe/styles/_themes.py +151 -0
  181. figrecipe/styles/presets/MATPLOTLIB.yaml +95 -0
  182. figrecipe/styles/presets/SCITEX.yaml +181 -0
  183. figrecipe-0.7.4.dist-info/METADATA +429 -0
  184. figrecipe-0.7.4.dist-info/RECORD +188 -0
  185. figrecipe/_reproducer.py +0 -358
  186. figrecipe-0.5.0.dist-info/METADATA +0 -336
  187. figrecipe-0.5.0.dist-info/RECORD +0 -26
  188. {figrecipe-0.5.0.dist-info → figrecipe-0.7.4.dist-info}/WHEEL +0 -0
  189. {figrecipe-0.5.0.dist-info → figrecipe-0.7.4.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,480 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Style override application for preview rendering."""
4
+
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ from matplotlib.axes import Axes
8
+ from matplotlib.figure import Figure
9
+
10
+
11
+ def apply_overrides(
12
+ fig: Figure, overrides: Dict[str, Any], record: Optional[Any] = None
13
+ ) -> None:
14
+ """
15
+ Apply style overrides to figure.
16
+
17
+ Parameters
18
+ ----------
19
+ fig : Figure
20
+ Matplotlib figure.
21
+ overrides : dict
22
+ Style overrides with keys like:
23
+ - axes_width_mm, axes_height_mm
24
+ - fonts_axis_label_pt, fonts_tick_label_pt
25
+ - lines_trace_mm
26
+ - etc.
27
+ record : FigureRecord, optional
28
+ Recording record to access call IDs for grouping elements.
29
+ """
30
+ from ..styles._style_applier import apply_style_mm
31
+
32
+ axes_list = fig.get_axes()
33
+
34
+ for ax in axes_list:
35
+ # Apply mm-based styling
36
+ apply_style_mm(ax, overrides)
37
+
38
+ # Apply specific overrides that aren't handled by apply_style_mm
39
+ _apply_font_overrides(ax, overrides)
40
+ _apply_tick_overrides(ax, overrides)
41
+ _apply_behavior_overrides(ax, overrides)
42
+ _apply_legend_overrides(ax, overrides)
43
+ _apply_line_overrides(ax, overrides)
44
+ _apply_marker_overrides(ax, overrides)
45
+
46
+ # Apply color palette to existing elements
47
+ color_palette = overrides.get("color_palette")
48
+ if color_palette is not None:
49
+ ax_record = _find_ax_record(ax, axes_list, record)
50
+ apply_color_palette(ax, color_palette, ax_record)
51
+
52
+
53
+ def _apply_font_overrides(ax: Axes, overrides: Dict[str, Any]) -> None:
54
+ """Apply font-related overrides to axes."""
55
+ # Font sizes (YAML: fonts_axis_label_pt, legacy: axis_font_size_pt)
56
+ axis_fs = overrides.get("fonts_axis_label_pt", overrides.get("axis_font_size_pt"))
57
+ if axis_fs is not None:
58
+ ax.xaxis.label.set_fontsize(axis_fs)
59
+ ax.yaxis.label.set_fontsize(axis_fs)
60
+
61
+ tick_fs = overrides.get("fonts_tick_label_pt", overrides.get("tick_font_size_pt"))
62
+ if tick_fs is not None:
63
+ ax.tick_params(labelsize=tick_fs)
64
+
65
+ title_fs = overrides.get("fonts_title_pt", overrides.get("title_font_size_pt"))
66
+ if title_fs is not None:
67
+ ax.title.set_fontsize(title_fs)
68
+
69
+ family = overrides.get("fonts_family", overrides.get("font_family"))
70
+ if family is not None:
71
+ ax.xaxis.label.set_fontfamily(family)
72
+ ax.yaxis.label.set_fontfamily(family)
73
+ ax.title.set_fontfamily(family)
74
+ for label in ax.get_xticklabels() + ax.get_yticklabels():
75
+ label.set_fontfamily(family)
76
+
77
+
78
+ def _apply_tick_overrides(ax: Axes, overrides: Dict[str, Any]) -> None:
79
+ """Apply tick-related overrides to axes."""
80
+ # Ticks (YAML: ticks_direction, legacy: tick_direction)
81
+ tick_dir = overrides.get("ticks_direction", overrides.get("tick_direction"))
82
+ if tick_dir is not None and tick_dir in ("in", "out", "inout"):
83
+ ax.tick_params(direction=tick_dir)
84
+
85
+ tick_len = overrides.get("ticks_length_mm", overrides.get("tick_length_mm"))
86
+ if tick_len is not None:
87
+ from .._utils._units import mm_to_pt
88
+
89
+ length = mm_to_pt(tick_len)
90
+ ax.tick_params(length=length)
91
+
92
+
93
+ def _apply_behavior_overrides(ax: Axes, overrides: Dict[str, Any]) -> None:
94
+ """Apply behavior-related overrides (grid, spines)."""
95
+ # Grid (YAML: behavior_grid, legacy: grid)
96
+ grid_value = overrides.get("behavior_grid", overrides.get("grid"))
97
+ if grid_value is not None:
98
+ if grid_value:
99
+ ax.grid(True, alpha=0.3)
100
+ else:
101
+ ax.grid(False)
102
+
103
+ # Spines (YAML: behavior_hide_top_spine, legacy: hide_top_spine)
104
+ hide_top = overrides.get("behavior_hide_top_spine", overrides.get("hide_top_spine"))
105
+ if hide_top is not None:
106
+ ax.spines["top"].set_visible(not hide_top)
107
+
108
+ hide_right = overrides.get(
109
+ "behavior_hide_right_spine", overrides.get("hide_right_spine")
110
+ )
111
+ if hide_right is not None:
112
+ ax.spines["right"].set_visible(not hide_right)
113
+
114
+
115
+ def _apply_legend_overrides(ax: Axes, overrides: Dict[str, Any]) -> None:
116
+ """Apply legend-related overrides."""
117
+ legend = ax.get_legend()
118
+ if legend is not None:
119
+ if "legend_frameon" in overrides:
120
+ legend.set_frame_on(overrides["legend_frameon"])
121
+
122
+ if "legend_alpha" in overrides:
123
+ frame = legend.get_frame()
124
+ fc = frame.get_facecolor()
125
+ frame.set_facecolor((*fc[:3], overrides["legend_alpha"]))
126
+
127
+
128
+ def _apply_line_overrides(ax: Axes, overrides: Dict[str, Any]) -> None:
129
+ """Apply line-related overrides."""
130
+ # Line widths (YAML: lines_trace_mm, legacy: trace_thickness_mm)
131
+ trace_mm = overrides.get("lines_trace_mm", overrides.get("trace_thickness_mm"))
132
+ if trace_mm is not None:
133
+ from .._utils._units import mm_to_pt
134
+
135
+ lw = mm_to_pt(trace_mm)
136
+ for line in ax.get_lines():
137
+ line.set_linewidth(lw)
138
+
139
+
140
+ def _apply_marker_overrides(ax: Axes, overrides: Dict[str, Any]) -> None:
141
+ """Apply marker-related overrides."""
142
+ # Marker sizes (YAML: markers_scatter_mm, legacy: marker_size_mm)
143
+ # Only apply to PathCollection (scatter), not PolyCollection (violin/fill)
144
+ scatter_mm = overrides.get(
145
+ "markers_scatter_mm",
146
+ overrides.get("markers_size_mm", overrides.get("marker_size_mm")),
147
+ )
148
+ if scatter_mm is not None:
149
+ from matplotlib.collections import PathCollection
150
+
151
+ from .._utils._units import mm_to_scatter_size
152
+
153
+ size = mm_to_scatter_size(scatter_mm)
154
+ for coll in ax.collections:
155
+ # Only apply to scatter plots (PathCollection), not violin/fill (PolyCollection)
156
+ if isinstance(coll, PathCollection):
157
+ try:
158
+ coll.set_sizes([size])
159
+ except Exception:
160
+ pass
161
+
162
+
163
+ def _find_ax_record(ax: Axes, axes_list: List[Axes], record: Optional[Any]) -> Any:
164
+ """Find the AxesRecord for a given axes."""
165
+ if record is None or not hasattr(record, "axes"):
166
+ return None
167
+
168
+ # Find ax position in the figure's axes list
169
+ ax_idx = axes_list.index(ax)
170
+ # AxesRecord keys are position tuples like "(0, 0)", "(0, 1)", etc.
171
+ # Try to match by index order
172
+ ax_keys = sorted(record.axes.keys())
173
+ if ax_idx < len(ax_keys):
174
+ return record.axes.get(ax_keys[ax_idx])
175
+ return None
176
+
177
+
178
+ def apply_color_palette(
179
+ ax: Axes, color_palette: List[Any], ax_record: Optional[Any] = None
180
+ ) -> None:
181
+ """
182
+ Apply color palette to existing plot elements, grouping by call_id.
183
+
184
+ Parameters
185
+ ----------
186
+ ax : Axes
187
+ Matplotlib axes containing plot elements.
188
+ color_palette : list
189
+ List of colors (RGB tuples or color names).
190
+ ax_record : AxesRecord, optional
191
+ Record of calls for this axes (for grouping elements by call_id).
192
+ """
193
+
194
+ # Normalize colors (RGB 0-255 to 0-1)
195
+ normalized_palette = _normalize_color_palette(color_palette)
196
+ if not normalized_palette:
197
+ return
198
+
199
+ # Build call_id to color index mapping from record
200
+ call_color_map = _build_call_color_map(ax_record)
201
+
202
+ # Apply to different element types
203
+ _apply_colors_to_lines(ax, normalized_palette, ax_record, call_color_map)
204
+ _apply_colors_to_bars(ax, normalized_palette, ax_record, call_color_map)
205
+ _apply_colors_to_pie(ax, normalized_palette, ax_record)
206
+ _apply_colors_to_scatter(ax, normalized_palette, ax_record, call_color_map)
207
+ _apply_colors_to_poly(ax, normalized_palette)
208
+ _update_legend_colors(ax, normalized_palette)
209
+
210
+
211
+ def _normalize_color_palette(color_palette: List[Any]) -> List[Any]:
212
+ """Normalize color palette to 0-1 range."""
213
+ normalized = []
214
+ for c in color_palette:
215
+ if isinstance(c, (list, tuple)) and len(c) >= 3:
216
+ if all(v <= 1.0 for v in c):
217
+ normalized.append(tuple(c))
218
+ else:
219
+ normalized.append(tuple(v / 255.0 for v in c))
220
+ else:
221
+ normalized.append(c)
222
+ return normalized
223
+
224
+
225
+ def _build_call_color_map(ax_record: Optional[Any]) -> Dict[str, int]:
226
+ """Build mapping from call_id to color index."""
227
+ call_color_map = {}
228
+ if ax_record:
229
+ color_idx = 0
230
+ for call in ax_record.calls:
231
+ if call.function in (
232
+ "plot",
233
+ "scatter",
234
+ "bar",
235
+ "barh",
236
+ "hist",
237
+ "pie",
238
+ "fill",
239
+ "fill_between",
240
+ "fill_betweenx",
241
+ ):
242
+ if call.id not in call_color_map:
243
+ call_color_map[call.id] = color_idx
244
+ color_idx += 1
245
+ return call_color_map
246
+
247
+
248
+ def _apply_colors_to_lines(
249
+ ax: Axes,
250
+ palette: List[Any],
251
+ ax_record: Optional[Any],
252
+ call_color_map: Dict[str, int],
253
+ ) -> None:
254
+ """Apply colors to line elements."""
255
+ lines = ax.get_lines()
256
+ line_calls = [
257
+ c for c in (ax_record.calls if ax_record else []) if c.function == "plot"
258
+ ]
259
+ for i, line in enumerate(lines):
260
+ # Skip internal lines (boxplot whiskers, etc.)
261
+ label = line.get_label()
262
+ if label.startswith("_"):
263
+ continue
264
+ if i < len(line_calls) and line_calls[i].id in call_color_map:
265
+ color_idx = call_color_map[line_calls[i].id]
266
+ else:
267
+ color_idx = i
268
+ line.set_color(palette[color_idx % len(palette)])
269
+
270
+
271
+ def _apply_colors_to_bars(
272
+ ax: Axes,
273
+ palette: List[Any],
274
+ ax_record: Optional[Any],
275
+ call_color_map: Dict[str, int],
276
+ ) -> None:
277
+ """Apply colors to bar and histogram elements."""
278
+ from matplotlib.patches import Rectangle
279
+
280
+ rectangles = [p for p in ax.patches if isinstance(p, Rectangle)]
281
+ if not rectangles:
282
+ return
283
+
284
+ bar_calls = [
285
+ c
286
+ for c in (ax_record.calls if ax_record else [])
287
+ if c.function in ("bar", "barh", "hist")
288
+ ]
289
+ if bar_calls:
290
+ rect_per_call = len(rectangles) // len(bar_calls) if bar_calls else 1
291
+ for i, patch in enumerate(rectangles):
292
+ call_idx = (
293
+ min(i // rect_per_call, len(bar_calls) - 1) if rect_per_call > 0 else 0
294
+ )
295
+ if call_idx < len(bar_calls) and bar_calls[call_idx].id in call_color_map:
296
+ color_idx = call_color_map[bar_calls[call_idx].id]
297
+ else:
298
+ color_idx = call_idx
299
+ patch.set_facecolor(palette[color_idx % len(palette)])
300
+ else:
301
+ # No record, apply single color to all bars
302
+ color = palette[0]
303
+ for patch in rectangles:
304
+ patch.set_facecolor(color)
305
+
306
+
307
+ def _apply_colors_to_pie(
308
+ ax: Axes, palette: List[Any], ax_record: Optional[Any] = None
309
+ ) -> None:
310
+ """Apply colors to pie chart wedges.
311
+
312
+ If the pie call has custom colors in kwargs, those are used.
313
+ Otherwise, the theme palette is applied.
314
+ """
315
+ from matplotlib.patches import Wedge
316
+
317
+ wedges = [p for p in ax.patches if isinstance(p, Wedge)]
318
+ if not wedges:
319
+ return
320
+
321
+ # Check if pie call has custom colors
322
+ custom_colors = None
323
+ if ax_record:
324
+ for call in ax_record.calls:
325
+ if call.function == "pie" and "colors" in call.kwargs:
326
+ custom_colors = call.kwargs["colors"]
327
+ break
328
+
329
+ # Use custom colors if available, otherwise use palette
330
+ if custom_colors and isinstance(custom_colors, list):
331
+ # Normalize custom colors
332
+ colors_to_use = []
333
+ for c in custom_colors:
334
+ if isinstance(c, str):
335
+ colors_to_use.append(c)
336
+ elif isinstance(c, (list, tuple)) and len(c) >= 3:
337
+ if all(v <= 1.0 for v in c):
338
+ colors_to_use.append(tuple(c))
339
+ else:
340
+ colors_to_use.append(tuple(v / 255.0 for v in c))
341
+ else:
342
+ colors_to_use.append(c)
343
+ else:
344
+ colors_to_use = palette
345
+
346
+ for i, wedge in enumerate(wedges):
347
+ color = colors_to_use[i % len(colors_to_use)]
348
+ wedge.set_facecolor(color)
349
+ wedge.set_edgecolor("black")
350
+
351
+
352
+ def _apply_colors_to_scatter(
353
+ ax: Axes,
354
+ palette: List[Any],
355
+ ax_record: Optional[Any],
356
+ call_color_map: Dict[str, int],
357
+ ) -> None:
358
+ """Apply colors to scatter plot collections."""
359
+ from matplotlib.collections import PathCollection
360
+
361
+ scatter_collections = [c for c in ax.collections if isinstance(c, PathCollection)]
362
+ scatter_calls = [
363
+ c for c in (ax_record.calls if ax_record else []) if c.function == "scatter"
364
+ ]
365
+ for i, coll in enumerate(scatter_collections):
366
+ if i < len(scatter_calls) and scatter_calls[i].id in call_color_map:
367
+ color_idx = call_color_map[scatter_calls[i].id]
368
+ else:
369
+ color_idx = i
370
+ coll.set_facecolor(palette[color_idx % len(palette)])
371
+
372
+
373
+ def _apply_colors_to_poly(ax: Axes, palette: List[Any]) -> None:
374
+ """Apply colors to polygon collections (violin, fill)."""
375
+ from matplotlib.collections import PolyCollection
376
+
377
+ poly_collections = [c for c in ax.collections if isinstance(c, PolyCollection)]
378
+ for i, coll in enumerate(poly_collections):
379
+ color = palette[i % len(palette)]
380
+ coll.set_facecolor(color)
381
+
382
+
383
+ def _update_legend_colors(ax: Axes, palette: List[Any]) -> None:
384
+ """Update legend colors to reflect new palette."""
385
+ legend = ax.get_legend()
386
+ if legend is None:
387
+ return
388
+
389
+ handles = (
390
+ legend.legend_handles
391
+ if hasattr(legend, "legend_handles")
392
+ else legend.legendHandles
393
+ )
394
+ for i, handle in enumerate(handles):
395
+ if i < len(palette):
396
+ color = palette[i % len(palette)]
397
+ if hasattr(handle, "set_color"):
398
+ handle.set_color(color)
399
+ elif hasattr(handle, "set_facecolor"):
400
+ handle.set_facecolor(color)
401
+
402
+
403
+ def apply_dark_mode(fig: Figure) -> None:
404
+ """
405
+ Apply dark mode colors to figure.
406
+
407
+ Parameters
408
+ ----------
409
+ fig : Figure
410
+ Matplotlib figure.
411
+ """
412
+ # Dark theme colors
413
+ bg_color = "#1a1a1a"
414
+ text_color = "#e8e8e8"
415
+
416
+ # Update rcParams for dark mode (pie charts, panel labels)
417
+ import matplotlib as mpl
418
+
419
+ mpl.rcParams["text.color"] = text_color
420
+ mpl.rcParams["axes.labelcolor"] = text_color
421
+ mpl.rcParams["xtick.color"] = text_color
422
+ mpl.rcParams["ytick.color"] = text_color
423
+
424
+ # Figure background
425
+ fig.patch.set_facecolor(bg_color)
426
+
427
+ # Figure-level text elements (suptitle, supxlabel, supylabel)
428
+ if hasattr(fig, "_suptitle") and fig._suptitle is not None:
429
+ fig._suptitle.set_color(text_color)
430
+ if hasattr(fig, "_supxlabel") and fig._supxlabel is not None:
431
+ fig._supxlabel.set_color(text_color)
432
+ if hasattr(fig, "_supylabel") and fig._supylabel is not None:
433
+ fig._supylabel.set_color(text_color)
434
+
435
+ for ax in fig.get_axes():
436
+ _apply_dark_mode_to_axes(ax, bg_color, text_color)
437
+
438
+
439
+ def _apply_dark_mode_to_axes(ax: Axes, bg_color: str, text_color: str) -> None:
440
+ """Apply dark mode colors to a single axes."""
441
+ # Axes background
442
+ ax.set_facecolor(bg_color)
443
+
444
+ # Text colors
445
+ ax.xaxis.label.set_color(text_color)
446
+ ax.yaxis.label.set_color(text_color)
447
+ ax.title.set_color(text_color)
448
+
449
+ # Tick labels and tick marks
450
+ ax.tick_params(colors=text_color, which="both")
451
+
452
+ # Explicitly set tick label colors (for specgram and other plots)
453
+ for label in ax.get_xticklabels() + ax.get_yticklabels():
454
+ label.set_color(text_color)
455
+
456
+ # Spines
457
+ for spine in ax.spines.values():
458
+ spine.set_color(text_color)
459
+
460
+ # All text objects on axes (panel labels, pie labels, annotations)
461
+ for text in ax.texts:
462
+ text.set_color(text_color)
463
+
464
+ # Legend
465
+ legend = ax.get_legend()
466
+ if legend is not None:
467
+ frame = legend.get_frame()
468
+ frame.set_facecolor(bg_color)
469
+ frame.set_edgecolor(text_color)
470
+ for text in legend.get_texts():
471
+ text.set_color(text_color)
472
+
473
+
474
+ __all__ = [
475
+ "apply_overrides",
476
+ "apply_color_palette",
477
+ "apply_dark_mode",
478
+ ]
479
+
480
+ # EOF
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Preview rendering with style overrides.
5
+
6
+ This module renders figure previews with user-specified style overrides
7
+ applied, enabling real-time preview updates in the GUI editor.
8
+ """
9
+
10
+ import io
11
+ from typing import Any, Dict, Optional, Tuple
12
+
13
+ from .._wrappers import RecordingFigure
14
+ from ._bbox import extract_bboxes
15
+ from ._render_overrides import apply_dark_mode, apply_overrides
16
+
17
+
18
+ def render_preview(
19
+ fig: RecordingFigure,
20
+ overrides: Optional[Dict[str, Any]] = None,
21
+ dpi: int = 150,
22
+ dark_mode: bool = False,
23
+ ) -> Tuple[bytes, Dict[str, Any], Tuple[int, int]]:
24
+ """
25
+ Render figure preview with style overrides applied.
26
+
27
+ Parameters
28
+ ----------
29
+ fig : RecordingFigure
30
+ Figure to render.
31
+ overrides : dict, optional
32
+ Style overrides to apply.
33
+ dpi : int, optional
34
+ Render resolution (default: 150).
35
+ dark_mode : bool, optional
36
+ Whether to render in dark mode (default: False).
37
+
38
+ Returns
39
+ -------
40
+ png_bytes : bytes
41
+ PNG image data.
42
+ bboxes : dict
43
+ Element bounding boxes.
44
+ img_size : tuple
45
+ (width, height) in pixels.
46
+ """
47
+ # Get underlying matplotlib figure
48
+ mpl_fig = fig.fig if hasattr(fig, "fig") else fig
49
+
50
+ # Get record for call_id grouping (if fig is a RecordingFigure)
51
+ record = fig.record if hasattr(fig, "record") else None
52
+
53
+ # Apply style overrides
54
+ if overrides:
55
+ apply_overrides(mpl_fig, overrides, record)
56
+
57
+ # Apply dark mode if requested
58
+ if dark_mode:
59
+ apply_dark_mode(mpl_fig)
60
+
61
+ # Finalize ticks and special plots (must be done after all plotting)
62
+ _finalize_figure(fig, mpl_fig)
63
+
64
+ # Render to buffer first
65
+ buf = io.BytesIO()
66
+ mpl_fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight")
67
+ buf.seek(0)
68
+ png_bytes = buf.read()
69
+
70
+ # Get image dimensions
71
+ from PIL import Image
72
+
73
+ buf.seek(0)
74
+ img = Image.open(buf)
75
+ img_width, img_height = img.size
76
+
77
+ # Set figure DPI to match render DPI and force canvas redraw for accurate bbox extraction
78
+ original_dpi = mpl_fig.dpi
79
+ mpl_fig.set_dpi(dpi)
80
+ mpl_fig.canvas.draw()
81
+
82
+ # Extract bounding boxes (with figure DPI matching render DPI)
83
+ bboxes = extract_bboxes(mpl_fig, img_width, img_height)
84
+
85
+ # Restore original DPI
86
+ mpl_fig.set_dpi(original_dpi)
87
+
88
+ return png_bytes, bboxes, (img_width, img_height)
89
+
90
+
91
+ def render_to_base64(
92
+ fig: RecordingFigure,
93
+ overrides: Optional[Dict[str, Any]] = None,
94
+ dpi: int = 150,
95
+ dark_mode: bool = False,
96
+ ) -> Tuple[str, Dict[str, Any], Tuple[int, int]]:
97
+ """
98
+ Render figure preview as base64 string.
99
+
100
+ Parameters
101
+ ----------
102
+ fig : RecordingFigure
103
+ Figure to render.
104
+ overrides : dict, optional
105
+ Style overrides to apply.
106
+ dpi : int, optional
107
+ Render resolution (default: 150).
108
+ dark_mode : bool, optional
109
+ Whether to render in dark mode (default: False).
110
+
111
+ Returns
112
+ -------
113
+ base64_str : str
114
+ Base64-encoded PNG image.
115
+ bboxes : dict
116
+ Element bounding boxes.
117
+ img_size : tuple
118
+ (width, height) in pixels.
119
+ """
120
+ import base64
121
+
122
+ png_bytes, bboxes, img_size = render_preview(fig, overrides, dpi, dark_mode)
123
+ base64_str = base64.b64encode(png_bytes).decode("utf-8")
124
+
125
+ return base64_str, bboxes, img_size
126
+
127
+
128
+ def render_download(
129
+ fig: RecordingFigure,
130
+ fmt: str = "png",
131
+ dpi: int = 300,
132
+ overrides: Optional[Dict[str, Any]] = None,
133
+ dark_mode: bool = False,
134
+ ) -> bytes:
135
+ """
136
+ Render figure for download in specified format.
137
+
138
+ Parameters
139
+ ----------
140
+ fig : RecordingFigure
141
+ Figure to render.
142
+ fmt : str
143
+ Output format: 'png', 'svg', 'pdf' (default: 'png').
144
+ dpi : int
145
+ Resolution for raster formats (default: 300).
146
+ overrides : dict, optional
147
+ Style overrides to apply.
148
+ dark_mode : bool, optional
149
+ Whether to render in dark mode.
150
+
151
+ Returns
152
+ -------
153
+ bytes
154
+ File content.
155
+ """
156
+ mpl_fig = fig.fig if hasattr(fig, "fig") else fig
157
+
158
+ # Get record for call_id grouping (if fig is a RecordingFigure)
159
+ record = fig.record if hasattr(fig, "record") else None
160
+
161
+ if overrides:
162
+ apply_overrides(mpl_fig, overrides, record)
163
+
164
+ if dark_mode:
165
+ apply_dark_mode(mpl_fig)
166
+
167
+ # Finalize ticks and special plots (must be done after all plotting)
168
+ _finalize_figure(fig, mpl_fig)
169
+
170
+ buf = io.BytesIO()
171
+ mpl_fig.savefig(buf, format=fmt, dpi=dpi, bbox_inches="tight")
172
+ buf.seek(0)
173
+
174
+ return buf.read()
175
+
176
+
177
+ def _finalize_figure(fig: RecordingFigure, mpl_fig: Any) -> None:
178
+ """Finalize ticks and special plots for all axes in the figure."""
179
+ from ..styles._style_applier import finalize_special_plots, finalize_ticks
180
+
181
+ # Get style dict for finalization
182
+ style_dict = {}
183
+ if hasattr(fig, "style") and fig.style:
184
+ from ..styles import get_style
185
+
186
+ style_dict = get_style(fig.style)
187
+
188
+ for ax in mpl_fig.get_axes():
189
+ finalize_ticks(ax)
190
+ finalize_special_plots(ax, style_dict)
191
+
192
+
193
+ __all__ = [
194
+ "render_preview",
195
+ "render_to_base64",
196
+ "render_download",
197
+ ]
198
+
199
+ # EOF