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,242 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Helper functions for the figure editor.
5
+ """
6
+
7
+ from typing import Any, Dict, Optional
8
+
9
+
10
+ def get_form_values_from_style(style: Dict[str, Any]) -> Dict[str, Any]:
11
+ """Extract form field values from a style dictionary.
12
+
13
+ Maps style dictionary values to HTML form input IDs.
14
+
15
+ Parameters
16
+ ----------
17
+ style : dict
18
+ Style configuration dictionary
19
+
20
+ Returns
21
+ -------
22
+ dict
23
+ Mapping of form input IDs to values
24
+ """
25
+ values = {}
26
+
27
+ # Axes dimensions
28
+ if "axes" in style:
29
+ values["axes_width_mm"] = style["axes"].get("width_mm", 80)
30
+ values["axes_height_mm"] = style["axes"].get("height_mm", 55)
31
+ values["axes_thickness_mm"] = style["axes"].get("thickness_mm", 0.2)
32
+
33
+ # Margins
34
+ if "margins" in style:
35
+ values["margins_left_mm"] = style["margins"].get("left_mm", 12)
36
+ values["margins_right_mm"] = style["margins"].get("right_mm", 3)
37
+ values["margins_bottom_mm"] = style["margins"].get("bottom_mm", 10)
38
+ values["margins_top_mm"] = style["margins"].get("top_mm", 3)
39
+
40
+ # Spacing
41
+ if "spacing" in style:
42
+ values["spacing_horizontal_mm"] = style["spacing"].get("horizontal_mm", 8)
43
+ values["spacing_vertical_mm"] = style["spacing"].get("vertical_mm", 8)
44
+
45
+ # Fonts
46
+ if "fonts" in style:
47
+ values["fonts_family"] = style["fonts"].get("family", "Arial")
48
+ values["fonts_axis_label_pt"] = style["fonts"].get("axis_label_pt", 7)
49
+ values["fonts_tick_label_pt"] = style["fonts"].get("tick_label_pt", 6)
50
+ values["fonts_title_pt"] = style["fonts"].get("title_pt", 8)
51
+ values["fonts_legend_pt"] = style["fonts"].get("legend_pt", 6)
52
+
53
+ # Ticks
54
+ if "ticks" in style:
55
+ values["ticks_length_mm"] = style["ticks"].get("length_mm", 1.0)
56
+ values["ticks_thickness_mm"] = style["ticks"].get("thickness_mm", 0.2)
57
+ values["ticks_direction"] = style["ticks"].get("direction", "out")
58
+
59
+ # Lines
60
+ if "lines" in style:
61
+ values["lines_trace_mm"] = style["lines"].get("trace_mm", 0.2)
62
+
63
+ # Markers
64
+ if "markers" in style:
65
+ values["markers_size_mm"] = style["markers"].get("size_mm", 0.8)
66
+
67
+ # Output
68
+ if "output" in style:
69
+ values["output_dpi"] = style["output"].get("dpi", 300)
70
+
71
+ # Behavior
72
+ if "behavior" in style:
73
+ values["behavior_hide_top_spine"] = style["behavior"].get(
74
+ "hide_top_spine", True
75
+ )
76
+ values["behavior_hide_right_spine"] = style["behavior"].get(
77
+ "hide_right_spine", True
78
+ )
79
+ values["behavior_grid"] = style["behavior"].get("grid", False)
80
+
81
+ # Legend
82
+ if "legend" in style:
83
+ values["legend_frameon"] = style["legend"].get("frameon", True)
84
+
85
+ return values
86
+
87
+
88
+ def render_with_overrides(
89
+ fig, overrides: Optional[Dict[str, Any]], dark_mode: bool = False
90
+ ):
91
+ """
92
+ Re-render figure with overrides applied directly.
93
+
94
+ Applies style overrides directly to the existing figure for reliable rendering.
95
+ """
96
+ import base64
97
+ import io
98
+ import warnings
99
+
100
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
101
+ from PIL import Image
102
+
103
+ from ._bbox import extract_bboxes
104
+
105
+ # Get the underlying matplotlib figure
106
+ new_fig = fig.fig if hasattr(fig, "fig") else fig
107
+
108
+ # Safety check: validate figure size before rendering
109
+ fig_width, fig_height = new_fig.get_size_inches()
110
+ dpi = 150
111
+ pixel_width = fig_width * dpi
112
+ pixel_height = fig_height * dpi
113
+
114
+ # Sanity check: prevent enormous figures (max 10000x10000 pixels)
115
+ MAX_PIXELS = 10000
116
+ if pixel_width > MAX_PIXELS or pixel_height > MAX_PIXELS:
117
+ # Reset to reasonable size
118
+ new_fig.set_size_inches(
119
+ min(fig_width, MAX_PIXELS / dpi), min(fig_height, MAX_PIXELS / dpi)
120
+ )
121
+
122
+ # Switch to Agg backend to avoid Tkinter thread issues
123
+ new_fig.set_canvas(FigureCanvasAgg(new_fig))
124
+
125
+ # Disable constrained_layout if present (can cause rendering issues)
126
+ layout_engine = new_fig.get_layout_engine()
127
+ if layout_engine is not None and hasattr(layout_engine, "__class__"):
128
+ layout_name = layout_engine.__class__.__name__
129
+ if "Constrained" in layout_name:
130
+ new_fig.set_layout_engine("none")
131
+
132
+ # Apply overrides directly to existing figure
133
+ # Get record for call_id grouping (if fig is a RecordingFigure)
134
+ record = fig.record if hasattr(fig, "record") else None
135
+ if overrides:
136
+ from ._render_overrides import apply_overrides
137
+
138
+ apply_overrides(new_fig, overrides, record)
139
+
140
+ # Apply dark mode if requested
141
+ if dark_mode:
142
+ from ._render_overrides import apply_dark_mode
143
+
144
+ apply_dark_mode(new_fig)
145
+
146
+ # Validate axes bounds before rendering (prevent infinite/invalid extents)
147
+ for ax in new_fig.get_axes():
148
+ xlim = ax.get_xlim()
149
+ ylim = ax.get_ylim()
150
+ # Check for invalid limits (inf, nan, or extremely large)
151
+ if any(not (-1e10 < v < 1e10) for v in xlim + ylim):
152
+ ax.set_xlim(-1, 1)
153
+ ax.set_ylim(-1, 1)
154
+
155
+ # Save to PNG using same params as static save
156
+ # Re-create canvas to ensure clean state (avoids matplotlib renderer issues)
157
+ # Also save/restore the figure's draw method which can get corrupted by _get_renderer
158
+ original_draw = getattr(new_fig, "draw", None)
159
+ new_fig.set_canvas(FigureCanvasAgg(new_fig))
160
+
161
+ buf = io.BytesIO()
162
+ with warnings.catch_warnings():
163
+ warnings.filterwarnings("ignore", "constrained_layout not applied")
164
+ warnings.filterwarnings("ignore", category=UserWarning)
165
+ try:
166
+ new_fig.savefig(buf, format="png", dpi=150, bbox_inches="tight")
167
+ except Exception:
168
+ # Fall back to saving without bbox_inches="tight"
169
+ # Catches matplotlib internal exceptions (e.g., Done from _get_renderer)
170
+ buf = io.BytesIO()
171
+ # Reset canvas and draw method to clean state
172
+ new_fig.set_canvas(FigureCanvasAgg(new_fig))
173
+ if original_draw is not None:
174
+ new_fig.draw = original_draw
175
+ try:
176
+ new_fig.savefig(buf, format="png", dpi=150)
177
+ except Exception:
178
+ # Last resort: create empty placeholder
179
+ from PIL import Image as PILImage
180
+
181
+ placeholder = PILImage.new("RGB", (400, 300), color=(240, 240, 240))
182
+ placeholder.save(buf, format="PNG")
183
+ buf.seek(0)
184
+ finally:
185
+ # Always restore original draw method to prevent corruption
186
+ if original_draw is not None and hasattr(new_fig, "draw"):
187
+ try:
188
+ new_fig.draw = original_draw
189
+ except AttributeError:
190
+ pass
191
+ buf.seek(0)
192
+ png_bytes = buf.read()
193
+ base64_str = base64.b64encode(png_bytes).decode("utf-8")
194
+
195
+ # Get image size
196
+ buf.seek(0)
197
+ img = Image.open(buf)
198
+ img_size = img.size
199
+
200
+ # Extract bboxes
201
+ # Ensure clean canvas state before drawing
202
+ new_fig.set_canvas(FigureCanvasAgg(new_fig))
203
+ original_dpi = new_fig.dpi
204
+ new_fig.set_dpi(150)
205
+ try:
206
+ new_fig.canvas.draw()
207
+ except Exception:
208
+ # Canvas draw failed, likely due to corrupted state - reset and retry
209
+ new_fig.set_canvas(FigureCanvasAgg(new_fig))
210
+ try:
211
+ new_fig.canvas.draw()
212
+ except Exception:
213
+ pass # If still fails, proceed with possibly stale bboxes
214
+ bboxes = extract_bboxes(new_fig, img_size[0], img_size[1])
215
+ new_fig.set_dpi(original_dpi)
216
+
217
+ return base64_str, bboxes, img_size
218
+
219
+
220
+ def to_json_serializable(obj):
221
+ """Convert numpy arrays and other non-serializable objects to JSON-safe types."""
222
+ import numpy as np
223
+
224
+ if isinstance(obj, np.ndarray):
225
+ return obj.tolist()
226
+ elif isinstance(obj, (np.integer, np.floating)):
227
+ return obj.item()
228
+ # Handle pandas Series
229
+ elif hasattr(obj, "values") and hasattr(obj, "tolist"):
230
+ return obj.tolist()
231
+ elif isinstance(obj, dict):
232
+ return {k: to_json_serializable(v) for k, v in obj.items()}
233
+ elif isinstance(obj, (list, tuple)):
234
+ return [to_json_serializable(item) for item in obj]
235
+ return obj
236
+
237
+
238
+ __all__ = [
239
+ "get_form_values_from_style",
240
+ "render_with_overrides",
241
+ "to_json_serializable",
242
+ ]
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Hitmap generation package.
4
+
5
+ This package provides hitmap generation for interactive element selection.
6
+ The main entry point is `generate_hitmap`.
7
+ """
8
+
9
+ # Re-export main functions from _hitmap_main
10
+ from .._hitmap_main import generate_hitmap, hitmap_to_base64
11
+
12
+ # Re-export from artist processing
13
+ from ._artists import (
14
+ process_collections,
15
+ process_figure_text,
16
+ process_images,
17
+ process_legend,
18
+ process_lines,
19
+ process_patches,
20
+ process_text,
21
+ )
22
+
23
+ # Re-export from color utilities
24
+ from ._colors import (
25
+ AXES_COLOR,
26
+ BACKGROUND_COLOR,
27
+ DISTINCT_COLORS,
28
+ hsv_to_rgb,
29
+ id_to_rgb,
30
+ mpl_color_to_hex,
31
+ normalize_color,
32
+ rgb_to_id,
33
+ )
34
+
35
+ # Re-export from detection utilities
36
+ from ._detect import detect_plot_types, is_boxplot_element, is_violin_element
37
+
38
+ # Re-export from restoration utilities
39
+ from ._restore import (
40
+ restore_axes_properties,
41
+ restore_backgrounds,
42
+ restore_figure_text,
43
+ )
44
+
45
+ __all__ = [
46
+ # Main functions
47
+ "generate_hitmap",
48
+ "hitmap_to_base64",
49
+ # Color utilities
50
+ "id_to_rgb",
51
+ "rgb_to_id",
52
+ "DISTINCT_COLORS",
53
+ "BACKGROUND_COLOR",
54
+ "AXES_COLOR",
55
+ "hsv_to_rgb",
56
+ "normalize_color",
57
+ "mpl_color_to_hex",
58
+ # Detection utilities
59
+ "detect_plot_types",
60
+ "is_boxplot_element",
61
+ "is_violin_element",
62
+ # Artist processing
63
+ "process_collections",
64
+ "process_figure_text",
65
+ "process_images",
66
+ "process_legend",
67
+ "process_lines",
68
+ "process_patches",
69
+ "process_text",
70
+ # Restoration utilities
71
+ "restore_axes_properties",
72
+ "restore_backgrounds",
73
+ "restore_figure_text",
74
+ ]
75
+
76
+ # EOF
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Artist processing package for hitmap generation."""
4
+
5
+ from ._collections import process_collections
6
+ from ._images import process_images
7
+ from ._lines import process_lines
8
+ from ._patches import process_patches
9
+ from ._text import process_figure_text, process_legend, process_text
10
+
11
+ __all__ = [
12
+ "process_lines",
13
+ "process_collections",
14
+ "process_patches",
15
+ "process_images",
16
+ "process_text",
17
+ "process_legend",
18
+ "process_figure_text",
19
+ ]
20
+
21
+ # EOF
@@ -0,0 +1,345 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Collection processing for hitmap generation."""
4
+
5
+ from typing import Any, Dict
6
+
7
+ from matplotlib.collections import (
8
+ LineCollection,
9
+ PathCollection,
10
+ PolyCollection,
11
+ )
12
+
13
+ from .._colors import id_to_rgb, mpl_color_to_hex, normalize_color
14
+ from .._detect import is_violin_element
15
+
16
+
17
+ def process_collections(
18
+ ax,
19
+ ax_idx: int,
20
+ element_id: int,
21
+ original_props: Dict[str, Any],
22
+ color_map: Dict[str, Any],
23
+ ax_info: Dict[str, Any],
24
+ ) -> int:
25
+ """Process collections (scatter, fills, etc.) on an axes.
26
+
27
+ Returns updated element_id.
28
+ """
29
+ from matplotlib.collections import QuadMesh
30
+ from matplotlib.contour import QuadContourSet
31
+ from matplotlib.quiver import Quiver
32
+
33
+ ax_plot_types = ax_info.get("types", set())
34
+ ax_call_ids = ax_info.get("call_ids", {})
35
+ has_violin = "violinplot" in ax_plot_types
36
+ has_record = len(ax_plot_types) > 0
37
+
38
+ violin_ids = list(ax_call_ids.get("violinplot", []))
39
+ scatter_ids = list(ax_call_ids.get("scatter", []))
40
+ quiver_ids = list(ax_call_ids.get("quiver", []))
41
+ fill_between_ids = list(ax_call_ids.get("fill_between", []))
42
+ pcolormesh_ids = list(ax_call_ids.get("pcolormesh", []))
43
+ contour_ids = list(ax_call_ids.get("contour", []))
44
+ contourf_ids = list(ax_call_ids.get("contourf", []))
45
+
46
+ violin_call_id = violin_ids[0] if violin_ids else None
47
+ pcolormesh_call_id = pcolormesh_ids[0] if pcolormesh_ids else None
48
+ contour_call_id = contour_ids[0] if contour_ids else None
49
+ contourf_call_id = contourf_ids[0] if contourf_ids else None
50
+ scatter_coll_idx = 0
51
+ fill_coll_idx = 0
52
+
53
+ for i, coll in enumerate(ax.collections):
54
+ if isinstance(coll, Quiver):
55
+ element_id = _process_quiver(
56
+ coll, i, ax_idx, element_id, original_props, color_map, quiver_ids
57
+ )
58
+ elif isinstance(coll, PathCollection):
59
+ element_id, scatter_coll_idx = _process_scatter(
60
+ coll,
61
+ i,
62
+ ax_idx,
63
+ element_id,
64
+ original_props,
65
+ color_map,
66
+ scatter_ids,
67
+ scatter_coll_idx,
68
+ )
69
+ elif isinstance(coll, PolyCollection):
70
+ element_id, fill_coll_idx = _process_polycoll(
71
+ coll,
72
+ i,
73
+ ax_idx,
74
+ element_id,
75
+ original_props,
76
+ color_map,
77
+ has_violin,
78
+ violin_call_id,
79
+ has_record,
80
+ ax,
81
+ fill_between_ids,
82
+ fill_coll_idx,
83
+ )
84
+ elif isinstance(coll, LineCollection):
85
+ element_id = _process_linecoll(
86
+ coll,
87
+ i,
88
+ ax_idx,
89
+ element_id,
90
+ original_props,
91
+ color_map,
92
+ has_violin,
93
+ violin_call_id,
94
+ )
95
+ elif isinstance(coll, QuadMesh):
96
+ element_id = _process_quadmesh(
97
+ coll, i, ax_idx, element_id, color_map, pcolormesh_call_id
98
+ )
99
+ elif isinstance(coll, QuadContourSet):
100
+ element_id = _process_contour(
101
+ coll,
102
+ i,
103
+ ax_idx,
104
+ element_id,
105
+ color_map,
106
+ contour_call_id,
107
+ contourf_call_id,
108
+ )
109
+
110
+ return element_id
111
+
112
+
113
+ def _process_quiver(coll, i, ax_idx, element_id, original_props, color_map, quiver_ids):
114
+ """Process Quiver collection."""
115
+ if not coll.get_visible():
116
+ return element_id
117
+
118
+ key = f"ax{ax_idx}_quiver{i}"
119
+ rgb = id_to_rgb(element_id)
120
+
121
+ original_props[key] = {"color": coll.get_facecolor().copy()}
122
+ coll.set_color(normalize_color(rgb))
123
+
124
+ call_id = quiver_ids[0] if quiver_ids else None
125
+ label = call_id or f"quiver_{i}"
126
+
127
+ color_map[key] = {
128
+ "id": element_id,
129
+ "type": "quiver",
130
+ "label": label,
131
+ "ax_index": ax_idx,
132
+ "rgb": list(rgb),
133
+ "call_id": call_id,
134
+ }
135
+ return element_id + 1
136
+
137
+
138
+ def _process_scatter(
139
+ coll,
140
+ i,
141
+ ax_idx,
142
+ element_id,
143
+ original_props,
144
+ color_map,
145
+ scatter_ids,
146
+ scatter_coll_idx,
147
+ ):
148
+ """Process PathCollection (scatter)."""
149
+ if not coll.get_visible():
150
+ return element_id, scatter_coll_idx
151
+
152
+ key = f"ax{ax_idx}_scatter{i}"
153
+ rgb = id_to_rgb(element_id)
154
+
155
+ original_props[key] = {
156
+ "facecolors": coll.get_facecolors().copy(),
157
+ "edgecolors": coll.get_edgecolors().copy(),
158
+ }
159
+
160
+ coll.set_facecolors([normalize_color(rgb)])
161
+ coll.set_edgecolors([normalize_color(rgb)])
162
+
163
+ orig_fc = original_props[key]["facecolors"]
164
+ orig_color = orig_fc[0] if len(orig_fc) > 0 else [0.5, 0.5, 0.5, 1]
165
+
166
+ orig_label = coll.get_label() or f"scatter_{i}"
167
+ if scatter_coll_idx < len(scatter_ids):
168
+ call_id = scatter_ids[scatter_coll_idx]
169
+ label = call_id
170
+ else:
171
+ call_id = f"scatter_{ax_idx}_{scatter_coll_idx}"
172
+ label = call_id if orig_label.startswith("_") else orig_label
173
+
174
+ color_map[key] = {
175
+ "id": element_id,
176
+ "type": "scatter",
177
+ "label": label,
178
+ "ax_index": ax_idx,
179
+ "rgb": list(rgb),
180
+ "original_color": mpl_color_to_hex(orig_color),
181
+ "call_id": call_id,
182
+ }
183
+ return element_id + 1, scatter_coll_idx + 1
184
+
185
+
186
+ def _process_polycoll(
187
+ coll,
188
+ i,
189
+ ax_idx,
190
+ element_id,
191
+ original_props,
192
+ color_map,
193
+ has_violin,
194
+ violin_call_id,
195
+ has_record,
196
+ ax,
197
+ fill_between_ids=None,
198
+ fill_coll_idx=0,
199
+ ):
200
+ """Process PolyCollection (fills, violin bodies)."""
201
+ if fill_between_ids is None:
202
+ fill_between_ids = []
203
+
204
+ if not coll.get_visible():
205
+ return element_id, fill_coll_idx
206
+
207
+ orig_label = coll.get_label() or ""
208
+
209
+ # Check if this is a fill_between element (should NOT be skipped)
210
+ has_fill_between = len(fill_between_ids) > 0
211
+
212
+ if has_record and not has_fill_between:
213
+ # Only skip _child/_nolegend if NOT from fill_between
214
+ if orig_label.startswith("_child") or orig_label.startswith("_nolegend"):
215
+ return element_id, fill_coll_idx
216
+
217
+ key = f"ax{ax_idx}_fill{i}"
218
+ rgb = id_to_rgb(element_id)
219
+
220
+ original_props[key] = {
221
+ "facecolors": coll.get_facecolors().copy(),
222
+ "edgecolors": coll.get_edgecolors().copy(),
223
+ }
224
+
225
+ coll.set_facecolors([normalize_color(rgb)])
226
+ coll.set_edgecolors([normalize_color(rgb)])
227
+
228
+ orig_fc = original_props[key]["facecolors"]
229
+ orig_color = orig_fc[0] if len(orig_fc) > 0 else [0.5, 0.5, 0.5, 1]
230
+
231
+ if has_violin and is_violin_element(coll, ax):
232
+ elem_type = "violin"
233
+ label = violin_call_id or "violin"
234
+ call_id = violin_call_id
235
+ else:
236
+ elem_type = "fill"
237
+ # Try to get fill_between call_id
238
+ if fill_coll_idx < len(fill_between_ids):
239
+ call_id = fill_between_ids[fill_coll_idx]
240
+ label = call_id
241
+ fill_coll_idx += 1
242
+ else:
243
+ label = orig_label if not orig_label.startswith("_") else f"fill_{i}"
244
+ call_id = None
245
+
246
+ color_map[key] = {
247
+ "id": element_id,
248
+ "type": elem_type,
249
+ "label": label,
250
+ "ax_index": ax_idx,
251
+ "rgb": list(rgb),
252
+ "original_color": mpl_color_to_hex(orig_color),
253
+ "call_id": call_id,
254
+ }
255
+ return element_id + 1, fill_coll_idx
256
+
257
+
258
+ def _process_linecoll(
259
+ coll, i, ax_idx, element_id, original_props, color_map, has_violin, violin_call_id
260
+ ):
261
+ """Process LineCollection (violin inner lines)."""
262
+ if not coll.get_visible():
263
+ return element_id
264
+
265
+ key = f"ax{ax_idx}_linecoll{i}"
266
+ rgb = id_to_rgb(element_id)
267
+
268
+ original_props[key] = {
269
+ "colors": coll.get_colors().copy() if hasattr(coll, "get_colors") else [],
270
+ "edgecolors": coll.get_edgecolors().copy(),
271
+ }
272
+
273
+ coll.set_color(normalize_color(rgb))
274
+
275
+ orig_colors = original_props[key]["colors"]
276
+ orig_color = orig_colors[0] if len(orig_colors) > 0 else [0.5, 0.5, 0.5, 1]
277
+
278
+ if has_violin:
279
+ elem_type = "violin"
280
+ label = violin_call_id or "violin"
281
+ call_id = violin_call_id
282
+ else:
283
+ elem_type = "linecollection"
284
+ label = f"linecoll_{i}"
285
+ call_id = None
286
+
287
+ color_map[key] = {
288
+ "id": element_id,
289
+ "type": elem_type,
290
+ "label": label,
291
+ "ax_index": ax_idx,
292
+ "rgb": list(rgb),
293
+ "original_color": mpl_color_to_hex(orig_color),
294
+ "call_id": call_id,
295
+ }
296
+ return element_id + 1
297
+
298
+
299
+ def _process_quadmesh(coll, i, ax_idx, element_id, color_map, pcolormesh_call_id=None):
300
+ """Process QuadMesh (pcolormesh, hexbin, hist2d)."""
301
+ if not coll.get_visible():
302
+ return element_id
303
+
304
+ key = f"ax{ax_idx}_quadmesh{i}"
305
+ rgb = id_to_rgb(element_id)
306
+
307
+ call_id = pcolormesh_call_id
308
+ label = call_id or f"quadmesh_{i}"
309
+
310
+ color_map[key] = {
311
+ "id": element_id,
312
+ "type": "quadmesh",
313
+ "label": label,
314
+ "ax_index": ax_idx,
315
+ "rgb": list(rgb),
316
+ "call_id": call_id,
317
+ }
318
+ return element_id + 1
319
+
320
+
321
+ def _process_contour(
322
+ coll, i, ax_idx, element_id, color_map, contour_call_id=None, contourf_call_id=None
323
+ ):
324
+ """Process QuadContourSet (contour, contourf)."""
325
+ key = f"ax{ax_idx}_contour{i}"
326
+ rgb = id_to_rgb(element_id)
327
+
328
+ # Try contourf first, then contour
329
+ call_id = contourf_call_id or contour_call_id
330
+ label = call_id or f"contour_{i}"
331
+
332
+ color_map[key] = {
333
+ "id": element_id,
334
+ "type": "contour",
335
+ "label": label,
336
+ "ax_index": ax_idx,
337
+ "rgb": list(rgb),
338
+ "call_id": call_id,
339
+ }
340
+ return element_id + 1
341
+
342
+
343
+ __all__ = ["process_collections"]
344
+
345
+ # EOF