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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. figrecipe/__init__.py +106 -973
  2. figrecipe/_api/__init__.py +48 -0
  3. figrecipe/_api/_extract.py +108 -0
  4. figrecipe/_api/_notebook.py +61 -0
  5. figrecipe/_api/_panel.py +46 -0
  6. figrecipe/_api/_save.py +191 -0
  7. figrecipe/_api/_seaborn_proxy.py +34 -0
  8. figrecipe/_api/_style_manager.py +153 -0
  9. figrecipe/_api/_subplots.py +333 -0
  10. figrecipe/_api/_validate.py +82 -0
  11. figrecipe/_dev/__init__.py +2 -93
  12. figrecipe/_dev/_plotters.py +76 -0
  13. figrecipe/_dev/_run_demos.py +56 -0
  14. figrecipe/_dev/demo_plotters/__init__.py +35 -166
  15. figrecipe/_dev/demo_plotters/_categories.py +81 -0
  16. figrecipe/_dev/demo_plotters/_figure_creators.py +119 -0
  17. figrecipe/_dev/demo_plotters/_helpers.py +31 -0
  18. figrecipe/_dev/demo_plotters/_registry.py +50 -0
  19. figrecipe/_dev/demo_plotters/bar_categorical/__init__.py +4 -0
  20. figrecipe/_dev/demo_plotters/contour_surface/__init__.py +4 -0
  21. figrecipe/_dev/demo_plotters/distribution/__init__.py +4 -0
  22. figrecipe/_dev/demo_plotters/image_matrix/__init__.py +4 -0
  23. figrecipe/_dev/demo_plotters/line_curve/__init__.py +4 -0
  24. figrecipe/_dev/demo_plotters/{plot_plot.py → line_curve/plot_plot.py} +3 -2
  25. figrecipe/_dev/demo_plotters/scatter_points/__init__.py +4 -0
  26. figrecipe/_dev/demo_plotters/special/__init__.py +4 -0
  27. figrecipe/_dev/demo_plotters/{plot_pie.py → special/plot_pie.py} +5 -1
  28. figrecipe/_dev/demo_plotters/spectral_signal/__init__.py +4 -0
  29. figrecipe/_dev/demo_plotters/vector_flow/__init__.py +4 -0
  30. figrecipe/_editor/__init__.py +57 -9
  31. figrecipe/_editor/_bbox/__init__.py +43 -0
  32. figrecipe/_editor/_bbox/_collections.py +177 -0
  33. figrecipe/_editor/_bbox/_elements.py +159 -0
  34. figrecipe/_editor/_bbox/_extract.py +256 -0
  35. figrecipe/_editor/_bbox/_extract_axes.py +370 -0
  36. figrecipe/_editor/_bbox/_extract_text.py +342 -0
  37. figrecipe/_editor/_bbox/_lines.py +173 -0
  38. figrecipe/_editor/_bbox/_transforms.py +146 -0
  39. figrecipe/_editor/_flask_app.py +68 -1039
  40. figrecipe/_editor/_helpers.py +242 -0
  41. figrecipe/_editor/_hitmap/__init__.py +76 -0
  42. figrecipe/_editor/_hitmap/_artists/__init__.py +21 -0
  43. figrecipe/_editor/_hitmap/_artists/_collections.py +345 -0
  44. figrecipe/_editor/_hitmap/_artists/_images.py +68 -0
  45. figrecipe/_editor/_hitmap/_artists/_lines.py +107 -0
  46. figrecipe/_editor/_hitmap/_artists/_patches.py +163 -0
  47. figrecipe/_editor/_hitmap/_artists/_text.py +190 -0
  48. figrecipe/_editor/_hitmap/_colors.py +181 -0
  49. figrecipe/_editor/_hitmap/_detect.py +137 -0
  50. figrecipe/_editor/_hitmap/_restore.py +154 -0
  51. figrecipe/_editor/_hitmap_main.py +182 -0
  52. figrecipe/_editor/_preferences.py +135 -0
  53. figrecipe/_editor/_render_overrides.py +480 -0
  54. figrecipe/_editor/_renderer.py +35 -185
  55. figrecipe/_editor/_routes_axis.py +453 -0
  56. figrecipe/_editor/_routes_core.py +284 -0
  57. figrecipe/_editor/_routes_element.py +317 -0
  58. figrecipe/_editor/_routes_style.py +223 -0
  59. figrecipe/_editor/_templates/__init__.py +78 -1
  60. figrecipe/_editor/_templates/_html.py +109 -13
  61. figrecipe/_editor/_templates/_scripts/__init__.py +120 -0
  62. figrecipe/_editor/_templates/_scripts/_api.py +228 -0
  63. figrecipe/_editor/_templates/_scripts/_colors.py +485 -0
  64. figrecipe/_editor/_templates/_scripts/_core.py +436 -0
  65. figrecipe/_editor/_templates/_scripts/_debug_snapshot.py +186 -0
  66. figrecipe/_editor/_templates/_scripts/_element_editor.py +310 -0
  67. figrecipe/_editor/_templates/_scripts/_files.py +195 -0
  68. figrecipe/_editor/_templates/_scripts/_hitmap.py +509 -0
  69. figrecipe/_editor/_templates/_scripts/_inspector.py +315 -0
  70. figrecipe/_editor/_templates/_scripts/_labels.py +464 -0
  71. figrecipe/_editor/_templates/_scripts/_legend_drag.py +265 -0
  72. figrecipe/_editor/_templates/_scripts/_modals.py +226 -0
  73. figrecipe/_editor/_templates/_scripts/_overlays.py +292 -0
  74. figrecipe/_editor/_templates/_scripts/_panel_drag.py +334 -0
  75. figrecipe/_editor/_templates/_scripts/_panel_position.py +279 -0
  76. figrecipe/_editor/_templates/_scripts/_selection.py +237 -0
  77. figrecipe/_editor/_templates/_scripts/_tabs.py +89 -0
  78. figrecipe/_editor/_templates/_scripts/_view_mode.py +107 -0
  79. figrecipe/_editor/_templates/_scripts/_zoom.py +179 -0
  80. figrecipe/_editor/_templates/_styles/__init__.py +69 -0
  81. figrecipe/_editor/_templates/_styles/_base.py +64 -0
  82. figrecipe/_editor/_templates/_styles/_buttons.py +206 -0
  83. figrecipe/_editor/_templates/_styles/_color_input.py +123 -0
  84. figrecipe/_editor/_templates/_styles/_controls.py +265 -0
  85. figrecipe/_editor/_templates/_styles/_dynamic_props.py +144 -0
  86. figrecipe/_editor/_templates/_styles/_forms.py +126 -0
  87. figrecipe/_editor/_templates/_styles/_hitmap.py +184 -0
  88. figrecipe/_editor/_templates/_styles/_inspector.py +90 -0
  89. figrecipe/_editor/_templates/_styles/_labels.py +118 -0
  90. figrecipe/_editor/_templates/_styles/_modals.py +98 -0
  91. figrecipe/_editor/_templates/_styles/_overlays.py +130 -0
  92. figrecipe/_editor/_templates/_styles/_preview.py +225 -0
  93. figrecipe/_editor/_templates/_styles/_selection.py +73 -0
  94. figrecipe/_params/_DECORATION_METHODS.py +6 -0
  95. figrecipe/_recorder.py +35 -106
  96. figrecipe/_recorder_utils.py +124 -0
  97. figrecipe/_reproducer/__init__.py +18 -0
  98. figrecipe/_reproducer/_core.py +498 -0
  99. figrecipe/_reproducer/_custom_plots.py +279 -0
  100. figrecipe/_reproducer/_seaborn.py +100 -0
  101. figrecipe/_reproducer/_violin.py +186 -0
  102. figrecipe/_signatures/_kwargs.py +273 -0
  103. figrecipe/_signatures/_loader.py +21 -423
  104. figrecipe/_signatures/_parsing.py +147 -0
  105. figrecipe/_wrappers/_axes.py +119 -910
  106. figrecipe/_wrappers/_axes_helpers.py +136 -0
  107. figrecipe/_wrappers/_axes_plots.py +418 -0
  108. figrecipe/_wrappers/_axes_seaborn.py +157 -0
  109. figrecipe/_wrappers/_figure.py +162 -0
  110. figrecipe/_wrappers/_panel_labels.py +127 -0
  111. figrecipe/_wrappers/_plot_helpers.py +143 -0
  112. figrecipe/_wrappers/_violin_helpers.py +180 -0
  113. figrecipe/styles/__init__.py +8 -6
  114. figrecipe/styles/_dotdict.py +72 -0
  115. figrecipe/styles/_finalize.py +134 -0
  116. figrecipe/styles/_fonts.py +77 -0
  117. figrecipe/styles/_kwargs_converter.py +178 -0
  118. figrecipe/styles/_plot_styles.py +209 -0
  119. figrecipe/styles/_style_applier.py +32 -478
  120. figrecipe/styles/_style_loader.py +16 -192
  121. figrecipe/styles/_themes.py +151 -0
  122. figrecipe/styles/presets/MATPLOTLIB.yaml +2 -1
  123. figrecipe/styles/presets/SCITEX.yaml +29 -24
  124. {figrecipe-0.6.0.dist-info → figrecipe-0.7.4.dist-info}/METADATA +37 -2
  125. figrecipe-0.7.4.dist-info/RECORD +188 -0
  126. figrecipe/_editor/_bbox.py +0 -978
  127. figrecipe/_editor/_hitmap.py +0 -937
  128. figrecipe/_editor/_templates/_scripts.py +0 -2778
  129. figrecipe/_editor/_templates/_styles.py +0 -1326
  130. figrecipe/_reproducer.py +0 -975
  131. figrecipe-0.6.0.dist-info/RECORD +0 -90
  132. /figrecipe/_dev/demo_plotters/{plot_bar.py → bar_categorical/plot_bar.py} +0 -0
  133. /figrecipe/_dev/demo_plotters/{plot_barh.py → bar_categorical/plot_barh.py} +0 -0
  134. /figrecipe/_dev/demo_plotters/{plot_contour.py → contour_surface/plot_contour.py} +0 -0
  135. /figrecipe/_dev/demo_plotters/{plot_contourf.py → contour_surface/plot_contourf.py} +0 -0
  136. /figrecipe/_dev/demo_plotters/{plot_tricontour.py → contour_surface/plot_tricontour.py} +0 -0
  137. /figrecipe/_dev/demo_plotters/{plot_tricontourf.py → contour_surface/plot_tricontourf.py} +0 -0
  138. /figrecipe/_dev/demo_plotters/{plot_tripcolor.py → contour_surface/plot_tripcolor.py} +0 -0
  139. /figrecipe/_dev/demo_plotters/{plot_triplot.py → contour_surface/plot_triplot.py} +0 -0
  140. /figrecipe/_dev/demo_plotters/{plot_boxplot.py → distribution/plot_boxplot.py} +0 -0
  141. /figrecipe/_dev/demo_plotters/{plot_ecdf.py → distribution/plot_ecdf.py} +0 -0
  142. /figrecipe/_dev/demo_plotters/{plot_hist.py → distribution/plot_hist.py} +0 -0
  143. /figrecipe/_dev/demo_plotters/{plot_hist2d.py → distribution/plot_hist2d.py} +0 -0
  144. /figrecipe/_dev/demo_plotters/{plot_violinplot.py → distribution/plot_violinplot.py} +0 -0
  145. /figrecipe/_dev/demo_plotters/{plot_hexbin.py → image_matrix/plot_hexbin.py} +0 -0
  146. /figrecipe/_dev/demo_plotters/{plot_imshow.py → image_matrix/plot_imshow.py} +0 -0
  147. /figrecipe/_dev/demo_plotters/{plot_matshow.py → image_matrix/plot_matshow.py} +0 -0
  148. /figrecipe/_dev/demo_plotters/{plot_pcolor.py → image_matrix/plot_pcolor.py} +0 -0
  149. /figrecipe/_dev/demo_plotters/{plot_pcolormesh.py → image_matrix/plot_pcolormesh.py} +0 -0
  150. /figrecipe/_dev/demo_plotters/{plot_spy.py → image_matrix/plot_spy.py} +0 -0
  151. /figrecipe/_dev/demo_plotters/{plot_errorbar.py → line_curve/plot_errorbar.py} +0 -0
  152. /figrecipe/_dev/demo_plotters/{plot_fill.py → line_curve/plot_fill.py} +0 -0
  153. /figrecipe/_dev/demo_plotters/{plot_fill_between.py → line_curve/plot_fill_between.py} +0 -0
  154. /figrecipe/_dev/demo_plotters/{plot_fill_betweenx.py → line_curve/plot_fill_betweenx.py} +0 -0
  155. /figrecipe/_dev/demo_plotters/{plot_stackplot.py → line_curve/plot_stackplot.py} +0 -0
  156. /figrecipe/_dev/demo_plotters/{plot_stairs.py → line_curve/plot_stairs.py} +0 -0
  157. /figrecipe/_dev/demo_plotters/{plot_step.py → line_curve/plot_step.py} +0 -0
  158. /figrecipe/_dev/demo_plotters/{plot_scatter.py → scatter_points/plot_scatter.py} +0 -0
  159. /figrecipe/_dev/demo_plotters/{plot_eventplot.py → special/plot_eventplot.py} +0 -0
  160. /figrecipe/_dev/demo_plotters/{plot_loglog.py → special/plot_loglog.py} +0 -0
  161. /figrecipe/_dev/demo_plotters/{plot_semilogx.py → special/plot_semilogx.py} +0 -0
  162. /figrecipe/_dev/demo_plotters/{plot_semilogy.py → special/plot_semilogy.py} +0 -0
  163. /figrecipe/_dev/demo_plotters/{plot_stem.py → special/plot_stem.py} +0 -0
  164. /figrecipe/_dev/demo_plotters/{plot_acorr.py → spectral_signal/plot_acorr.py} +0 -0
  165. /figrecipe/_dev/demo_plotters/{plot_angle_spectrum.py → spectral_signal/plot_angle_spectrum.py} +0 -0
  166. /figrecipe/_dev/demo_plotters/{plot_cohere.py → spectral_signal/plot_cohere.py} +0 -0
  167. /figrecipe/_dev/demo_plotters/{plot_csd.py → spectral_signal/plot_csd.py} +0 -0
  168. /figrecipe/_dev/demo_plotters/{plot_magnitude_spectrum.py → spectral_signal/plot_magnitude_spectrum.py} +0 -0
  169. /figrecipe/_dev/demo_plotters/{plot_phase_spectrum.py → spectral_signal/plot_phase_spectrum.py} +0 -0
  170. /figrecipe/_dev/demo_plotters/{plot_psd.py → spectral_signal/plot_psd.py} +0 -0
  171. /figrecipe/_dev/demo_plotters/{plot_specgram.py → spectral_signal/plot_specgram.py} +0 -0
  172. /figrecipe/_dev/demo_plotters/{plot_xcorr.py → spectral_signal/plot_xcorr.py} +0 -0
  173. /figrecipe/_dev/demo_plotters/{plot_barbs.py → vector_flow/plot_barbs.py} +0 -0
  174. /figrecipe/_dev/demo_plotters/{plot_quiver.py → vector_flow/plot_quiver.py} +0 -0
  175. /figrecipe/_dev/demo_plotters/{plot_streamplot.py → vector_flow/plot_streamplot.py} +0 -0
  176. {figrecipe-0.6.0.dist-info → figrecipe-0.7.4.dist-info}/WHEEL +0 -0
  177. {figrecipe-0.6.0.dist-info → figrecipe-0.7.4.dist-info}/licenses/LICENSE +0 -0
@@ -1,937 +0,0 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- """
4
- Hitmap generation for interactive element selection.
5
-
6
- This module generates color-coded images where each figure element
7
- (line, scatter, bar, text, etc.) is rendered with a unique RGB color.
8
- This enables precise pixel-based element detection when users click
9
- on the figure preview.
10
-
11
- The color encoding uses 24-bit RGB:
12
- - First 12 elements: hand-picked visually distinct colors
13
- - Elements 13+: HSV-based generation for deterministic uniqueness
14
- """
15
-
16
- import io
17
- from typing import Any, Dict, Tuple
18
-
19
- from matplotlib.collections import LineCollection, PathCollection, PolyCollection
20
- from matplotlib.figure import Figure
21
- from matplotlib.image import AxesImage
22
- from matplotlib.patches import Rectangle
23
- from PIL import Image
24
-
25
- # Hand-picked distinct colors for first 12 elements (maximum visual distinction)
26
- DISTINCT_COLORS = [
27
- (255, 0, 0), # Red
28
- (0, 200, 0), # Green
29
- (0, 100, 255), # Blue
30
- (255, 200, 0), # Yellow
31
- (255, 0, 255), # Magenta
32
- (0, 255, 255), # Cyan
33
- (255, 128, 0), # Orange
34
- (128, 0, 255), # Purple
35
- (0, 255, 128), # Spring green
36
- (255, 0, 128), # Rose
37
- (128, 255, 0), # Lime
38
- (0, 128, 255), # Sky blue
39
- ]
40
-
41
- # Reserved colors
42
- BACKGROUND_COLOR = (26, 26, 26) # Dark gray for background
43
- AXES_COLOR = (64, 64, 64) # Medium gray for non-selectable axes elements
44
-
45
-
46
- def id_to_rgb(element_id: int) -> Tuple[int, int, int]:
47
- """
48
- Convert element ID to unique RGB color.
49
-
50
- Parameters
51
- ----------
52
- element_id : int
53
- Unique element identifier (1-based).
54
-
55
- Returns
56
- -------
57
- tuple of int
58
- RGB color tuple (0-255 range).
59
-
60
- Notes
61
- -----
62
- - ID 0 is reserved for background
63
- - IDs 1-12 use hand-picked distinct colors
64
- - IDs 13+ use HSV-based generation
65
- """
66
- if element_id <= 0:
67
- return BACKGROUND_COLOR
68
-
69
- if element_id <= len(DISTINCT_COLORS):
70
- return DISTINCT_COLORS[element_id - 1]
71
-
72
- # HSV-based generation for IDs > 12
73
- # Use golden ratio for hue distribution
74
- golden_ratio = 0.618033988749895
75
- hue = ((element_id - len(DISTINCT_COLORS)) * golden_ratio) % 1.0
76
-
77
- # High saturation and value for visibility
78
- saturation = 0.7 + (element_id % 3) * 0.1 # 0.7-0.9
79
- value = 0.75 + (element_id % 4) * 0.0625 # 0.75-0.9375
80
-
81
- # HSV to RGB conversion
82
- return _hsv_to_rgb(hue, saturation, value)
83
-
84
-
85
- def rgb_to_id(rgb: Tuple[int, int, int]) -> int:
86
- """
87
- Convert RGB color back to element ID.
88
-
89
- Parameters
90
- ----------
91
- rgb : tuple of int
92
- RGB color tuple.
93
-
94
- Returns
95
- -------
96
- int
97
- Element ID (0 if background/unknown).
98
- """
99
- if rgb == BACKGROUND_COLOR or rgb == AXES_COLOR:
100
- return 0
101
-
102
- # Check distinct colors first
103
- for i, color in enumerate(DISTINCT_COLORS):
104
- if rgb == color:
105
- return i + 1
106
-
107
- # For HSV-generated colors, we'd need reverse lookup
108
- # In practice, we use the color_map dict instead
109
- return 0
110
-
111
-
112
- def _hsv_to_rgb(h: float, s: float, v: float) -> Tuple[int, int, int]:
113
- """Convert HSV to RGB (0-255 range)."""
114
- if s == 0.0:
115
- r = g = b = int(v * 255)
116
- return (r, g, b)
117
-
118
- i = int(h * 6.0)
119
- f = (h * 6.0) - i
120
- p = v * (1.0 - s)
121
- q = v * (1.0 - s * f)
122
- t = v * (1.0 - s * (1.0 - f))
123
- i = i % 6
124
-
125
- if i == 0:
126
- r, g, b = v, t, p
127
- elif i == 1:
128
- r, g, b = q, v, p
129
- elif i == 2:
130
- r, g, b = p, v, t
131
- elif i == 3:
132
- r, g, b = p, q, v
133
- elif i == 4:
134
- r, g, b = t, p, v
135
- else:
136
- r, g, b = v, p, q
137
-
138
- return (int(r * 255), int(g * 255), int(b * 255))
139
-
140
-
141
- def _normalize_color(rgb: Tuple[int, int, int]) -> Tuple[float, float, float]:
142
- """Convert RGB 0-255 to matplotlib 0-1 format."""
143
- return (rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0)
144
-
145
-
146
- def _mpl_color_to_hex(color) -> str:
147
- """
148
- Convert matplotlib color to hex string for CSS.
149
-
150
- Parameters
151
- ----------
152
- color : str, tuple, or array-like
153
- Matplotlib color (name, hex, RGB/RGBA tuple 0-1 or 0-255).
154
-
155
- Returns
156
- -------
157
- str
158
- Hex color string like '#ff0000'.
159
- """
160
- import matplotlib.colors as mcolors
161
-
162
- try:
163
- # Handle named colors and hex strings
164
- if isinstance(color, str):
165
- rgba = mcolors.to_rgba(color)
166
- # Handle RGBA/RGB arrays (0-1 range from matplotlib)
167
- elif hasattr(color, "__len__"):
168
- if len(color) >= 3:
169
- # Check if it's 0-1 or 0-255 range
170
- if all(0 <= c <= 1 for c in color[:3]):
171
- rgba = (
172
- tuple(color[:3]) + (1.0,) if len(color) == 3 else tuple(color)
173
- )
174
- else:
175
- # Assume 0-255 range
176
- rgba = (color[0] / 255, color[1] / 255, color[2] / 255, 1.0)
177
- else:
178
- return "#888888" # fallback
179
- else:
180
- return "#888888" # fallback
181
-
182
- # Convert to hex
183
- r, g, b = int(rgba[0] * 255), int(rgba[1] * 255), int(rgba[2] * 255)
184
- return f"#{r:02x}{g:02x}{b:02x}"
185
- except Exception:
186
- return "#888888" # fallback gray
187
-
188
-
189
- def _detect_plot_types(fig) -> Dict[int, Dict[str, Any]]:
190
- """
191
- Detect plot types and call IDs used on each axes from RecordingFigure.
192
-
193
- Parameters
194
- ----------
195
- fig : Figure or RecordingFigure
196
- Figure to analyze.
197
-
198
- Returns
199
- -------
200
- dict
201
- Mapping from ax_index to plot info:
202
- {ax_idx: {
203
- 'types': set(),
204
- 'call_ids': {'boxplot': ['bp1'], 'plot': ['line1', 'line2'], ...}
205
- }}
206
- """
207
- plot_info = {}
208
-
209
- # Check if fig is a RecordingFigure with record
210
- record = None
211
- if hasattr(fig, "record"):
212
- record = fig.record
213
- elif hasattr(fig, "fig") and hasattr(fig.fig, "_recording_figure"):
214
- # Wrapped figure
215
- record = (
216
- fig.fig._recording_figure.record
217
- if hasattr(fig.fig._recording_figure, "record")
218
- else None
219
- )
220
-
221
- if record is None:
222
- return plot_info
223
-
224
- # Analyze recorded calls
225
- axes_list = (
226
- fig.get_axes()
227
- if hasattr(fig, "get_axes")
228
- else (fig.fig.get_axes() if hasattr(fig, "fig") else [])
229
- )
230
-
231
- # Calculate ncols from record
232
- max_col = 0
233
- for ax_key in record.axes.keys():
234
- parts = ax_key.split("_")
235
- if len(parts) >= 3:
236
- max_col = max(max_col, int(parts[2]))
237
- ncols = max_col + 1
238
-
239
- for ax_key, ax_record in record.axes.items():
240
- # Parse ax position to index
241
- parts = ax_key.split("_")
242
- if len(parts) >= 3:
243
- row, col = int(parts[1]), int(parts[2])
244
- # Calculate axes index (row-major order for grid layouts)
245
- ax_idx = row * ncols + col
246
-
247
- if ax_idx < len(axes_list):
248
- if ax_idx not in plot_info:
249
- plot_info[ax_idx] = {"types": set(), "call_ids": {}}
250
-
251
- for call in ax_record.calls:
252
- # Track ALL call types and their IDs
253
- plot_info[ax_idx]["types"].add(call.function)
254
- if call.function not in plot_info[ax_idx]["call_ids"]:
255
- plot_info[ax_idx]["call_ids"][call.function] = []
256
- plot_info[ax_idx]["call_ids"][call.function].append(call.id)
257
-
258
- return plot_info
259
-
260
-
261
- def _is_boxplot_element(line, ax) -> bool:
262
- """Check if a Line2D is part of a boxplot based on its properties."""
263
- label = line.get_label()
264
- # Boxplot elements typically have labels like "_child0", "_nolegend_"
265
- # - _childN: median lines (5 points), boxes
266
- # - _nolegend_: whiskers, caps (2 points)
267
- if label.startswith("_child"):
268
- # This is a boxplot median line or box element
269
- return True
270
- if label == "_nolegend_":
271
- # Check for whisker/cap (2-point lines)
272
- xdata, ydata = line.get_xdata(), line.get_ydata()
273
- if len(xdata) == 2 and len(ydata) == 2:
274
- return True
275
- return False
276
-
277
-
278
- def _is_violin_element(coll, ax) -> bool:
279
- """Check if a PolyCollection is part of a violinplot."""
280
- # Violin bodies are PolyCollection with fill
281
- if hasattr(coll, "get_facecolor"):
282
- fc = coll.get_facecolor()
283
- if len(fc) > 0 and fc[0][3] > 0: # Has visible fill
284
- return True
285
- return False
286
-
287
-
288
- def generate_hitmap(
289
- fig: Figure,
290
- dpi: int = 150,
291
- include_text: bool = True,
292
- ) -> Tuple[Image.Image, Dict[str, Any]]:
293
- """
294
- Generate hitmap with unique colors per element.
295
-
296
- Parameters
297
- ----------
298
- fig : matplotlib.figure.Figure
299
- Figure to generate hitmap for.
300
- dpi : int, optional
301
- Resolution for hitmap rendering (default: 150).
302
- include_text : bool, optional
303
- Whether to include text elements like labels (default: True).
304
-
305
- Returns
306
- -------
307
- hitmap : PIL.Image.Image
308
- RGB image where each element has unique color.
309
- color_map : dict
310
- Mapping from element key to metadata:
311
- {
312
- 'element_key': {
313
- 'id': int,
314
- 'type': str, # 'line', 'scatter', 'bar', 'boxplot', 'violin', etc.
315
- 'label': str,
316
- 'ax_index': int,
317
- 'rgb': [r, g, b],
318
- }
319
- }
320
- """
321
- # Store original properties for restoration
322
- original_props = {}
323
- color_map = {}
324
- element_id = 1
325
-
326
- # Detect plot types from record
327
- plot_types = _detect_plot_types(fig)
328
-
329
- # Get all axes (handle RecordingFigure wrapper)
330
- if hasattr(fig, "fig"):
331
- mpl_fig = fig.fig
332
- else:
333
- mpl_fig = fig
334
- axes_list = mpl_fig.get_axes()
335
-
336
- # Collect all artists and assign colors
337
- for ax_idx, ax in enumerate(axes_list):
338
- # Get plot info for this axes
339
- ax_info = plot_types.get(ax_idx, {"types": set(), "call_ids": {}})
340
- ax_plot_types = ax_info.get("types", set())
341
- ax_call_ids = ax_info.get("call_ids", {})
342
- has_boxplot = "boxplot" in ax_plot_types
343
- has_violin = "violinplot" in ax_plot_types
344
-
345
- # Get call_ids for each plot type (as queues to pop from)
346
- boxplot_ids = list(ax_call_ids.get("boxplot", []))
347
- violin_ids = list(ax_call_ids.get("violinplot", []))
348
- plot_ids = list(ax_call_ids.get("plot", []))
349
- scatter_ids = list(ax_call_ids.get("scatter", []))
350
- bar_ids = list(ax_call_ids.get("bar", []))
351
-
352
- # Current call_id for multi-element plot types (boxplot/violin)
353
- boxplot_call_id = boxplot_ids[0] if boxplot_ids else None
354
- violin_call_id = violin_ids[0] if violin_ids else None
355
-
356
- # Counter for regular lines (to map to plot call IDs)
357
- regular_line_idx = 0
358
-
359
- # Process lines (traces)
360
- for i, line in enumerate(ax.get_lines()):
361
- if not line.get_visible():
362
- continue
363
-
364
- # Get label for filtering
365
- orig_label = line.get_label() or ""
366
-
367
- # Skip internal child elements for non-boxplot/non-violin axes
368
- # (e.g., triplot, tricontour create _child0, _child1 lines)
369
- if orig_label.startswith("_child") and not has_boxplot and not has_violin:
370
- continue
371
-
372
- # Skip empty lines
373
- xdata = line.get_xdata()
374
- if len(xdata) == 0:
375
- continue
376
-
377
- key = f"ax{ax_idx}_line{i}"
378
- rgb = id_to_rgb(element_id)
379
-
380
- original_props[key] = {
381
- "color": line.get_color(),
382
- "markerfacecolor": line.get_markerfacecolor(),
383
- "markeredgecolor": line.get_markeredgecolor(),
384
- }
385
-
386
- line.set_color(_normalize_color(rgb))
387
- line.set_markerfacecolor(_normalize_color(rgb))
388
- line.set_markeredgecolor(_normalize_color(rgb))
389
-
390
- # Determine element type and call_id
391
- call_id = None
392
-
393
- if has_boxplot and (
394
- _is_boxplot_element(line, ax)
395
- or orig_label.startswith("_") # All _-prefixed on boxplot axes
396
- ):
397
- elem_type = "boxplot"
398
- label = boxplot_call_id or "boxplot"
399
- call_id = boxplot_call_id
400
- elif has_violin and orig_label.startswith("_"):
401
- elem_type = "violin"
402
- label = violin_call_id or "violin"
403
- call_id = violin_call_id
404
- else:
405
- # Regular line - map to plot call IDs
406
- elem_type = "line"
407
- label = orig_label if orig_label else f"line_{i}"
408
- if regular_line_idx < len(plot_ids):
409
- call_id = plot_ids[regular_line_idx]
410
- label = call_id # Use call_id as label
411
- else:
412
- # Fallback: generate synthetic call_id when no record
413
- call_id = f"line_{ax_idx}_{regular_line_idx}"
414
- if orig_label.startswith("_"):
415
- label = call_id
416
- regular_line_idx += 1
417
-
418
- color_map[key] = {
419
- "id": element_id,
420
- "type": elem_type,
421
- "label": label,
422
- "ax_index": ax_idx,
423
- "rgb": list(rgb),
424
- "original_color": _mpl_color_to_hex(original_props[key]["color"]),
425
- "call_id": call_id, # For logical grouping
426
- }
427
- element_id += 1
428
-
429
- # Counter for scatter collections
430
- scatter_coll_idx = 0
431
-
432
- # Process scatter plots (PathCollection)
433
- for i, coll in enumerate(ax.collections):
434
- if isinstance(coll, PathCollection):
435
- if not coll.get_visible():
436
- continue
437
-
438
- key = f"ax{ax_idx}_scatter{i}"
439
- rgb = id_to_rgb(element_id)
440
-
441
- original_props[key] = {
442
- "facecolors": coll.get_facecolors().copy(),
443
- "edgecolors": coll.get_edgecolors().copy(),
444
- }
445
-
446
- coll.set_facecolors([_normalize_color(rgb)])
447
- coll.set_edgecolors([_normalize_color(rgb)])
448
-
449
- # Get original facecolor for hover effect
450
- orig_fc = original_props[key]["facecolors"]
451
- orig_color = orig_fc[0] if len(orig_fc) > 0 else [0.5, 0.5, 0.5, 1]
452
-
453
- # Map to scatter call IDs
454
- orig_label = coll.get_label() or f"scatter_{i}"
455
- call_id = None
456
- label = orig_label
457
- if scatter_coll_idx < len(scatter_ids):
458
- call_id = scatter_ids[scatter_coll_idx]
459
- label = call_id # Use call_id as label
460
- else:
461
- # Fallback: generate synthetic call_id when no record
462
- call_id = f"scatter_{ax_idx}_{scatter_coll_idx}"
463
- if orig_label.startswith("_"):
464
- label = call_id
465
- scatter_coll_idx += 1
466
-
467
- color_map[key] = {
468
- "id": element_id,
469
- "type": "scatter",
470
- "label": label,
471
- "ax_index": ax_idx,
472
- "rgb": list(rgb),
473
- "original_color": _mpl_color_to_hex(orig_color),
474
- "call_id": call_id, # For logical grouping
475
- }
476
- element_id += 1
477
-
478
- elif isinstance(coll, PolyCollection):
479
- # Fill areas
480
- if not coll.get_visible():
481
- continue
482
-
483
- # Get label for filtering and identification
484
- orig_label = coll.get_label() or ""
485
-
486
- # Skip internal child elements (e.g., from barbs/quiver plots)
487
- # These have labels like "_child0", "_child1", etc.
488
- if orig_label.startswith("_child"):
489
- continue
490
-
491
- # Skip other internal matplotlib elements
492
- if orig_label.startswith("_nolegend"):
493
- continue
494
-
495
- key = f"ax{ax_idx}_fill{i}"
496
- rgb = id_to_rgb(element_id)
497
-
498
- original_props[key] = {
499
- "facecolors": coll.get_facecolors().copy(),
500
- "edgecolors": coll.get_edgecolors().copy(),
501
- }
502
-
503
- coll.set_facecolors([_normalize_color(rgb)])
504
- coll.set_edgecolors([_normalize_color(rgb)])
505
-
506
- # Get original facecolor for hover effect
507
- orig_fc = original_props[key]["facecolors"]
508
- orig_color = orig_fc[0] if len(orig_fc) > 0 else [0.5, 0.5, 0.5, 1]
509
-
510
- # Determine element type and label
511
- if has_violin and _is_violin_element(coll, ax):
512
- elem_type = "violin"
513
- label = violin_call_id or "violin"
514
- else:
515
- elem_type = "fill"
516
- # Use a sensible label, not internal _-prefixed names
517
- label = (
518
- orig_label if not orig_label.startswith("_") else f"fill_{i}"
519
- )
520
-
521
- # Add call_id for logical grouping
522
- call_id = None
523
- if elem_type == "violin":
524
- call_id = violin_call_id
525
-
526
- color_map[key] = {
527
- "id": element_id,
528
- "type": elem_type,
529
- "label": label,
530
- "ax_index": ax_idx,
531
- "rgb": list(rgb),
532
- "original_color": _mpl_color_to_hex(orig_color),
533
- "call_id": call_id, # For logical grouping
534
- }
535
- element_id += 1
536
-
537
- elif isinstance(coll, LineCollection):
538
- # Violin inner lines (cbars, cmins, cmaxes)
539
- if not coll.get_visible():
540
- continue
541
-
542
- key = f"ax{ax_idx}_linecoll{i}"
543
- rgb = id_to_rgb(element_id)
544
-
545
- original_props[key] = {
546
- "colors": coll.get_colors().copy()
547
- if hasattr(coll, "get_colors")
548
- else [],
549
- "edgecolors": coll.get_edgecolors().copy(),
550
- }
551
-
552
- coll.set_color(_normalize_color(rgb))
553
-
554
- # Get original color for hover effect
555
- orig_colors = original_props[key]["colors"]
556
- orig_color = (
557
- orig_colors[0] if len(orig_colors) > 0 else [0.5, 0.5, 0.5, 1]
558
- )
559
-
560
- # Determine element type - violin inner lines on violin axes
561
- if has_violin:
562
- elem_type = "violin"
563
- label = violin_call_id or "violin"
564
- call_id = violin_call_id
565
- else:
566
- elem_type = "linecollection"
567
- label = f"linecoll_{i}"
568
- call_id = None
569
-
570
- color_map[key] = {
571
- "id": element_id,
572
- "type": elem_type,
573
- "label": label,
574
- "ax_index": ax_idx,
575
- "rgb": list(rgb),
576
- "original_color": _mpl_color_to_hex(orig_color),
577
- "call_id": call_id, # For logical grouping
578
- }
579
- element_id += 1
580
-
581
- # Get bar call_id (all bars from a single bar() call share the same ID)
582
- # Fallback: generate synthetic call_id when no record
583
- bar_call_id = bar_ids[0] if bar_ids else f"bar_{ax_idx}"
584
-
585
- # Process bars (Rectangle patches)
586
- for i, patch in enumerate(ax.patches):
587
- if isinstance(patch, Rectangle):
588
- if not patch.get_visible():
589
- continue
590
- # Skip axes frame rectangles
591
- if patch.get_width() == 1.0 and patch.get_height() == 1.0:
592
- continue
593
-
594
- key = f"ax{ax_idx}_bar{i}"
595
- rgb = id_to_rgb(element_id)
596
-
597
- original_props[key] = {
598
- "facecolor": patch.get_facecolor(),
599
- "edgecolor": patch.get_edgecolor(),
600
- }
601
-
602
- patch.set_facecolor(_normalize_color(rgb))
603
- patch.set_edgecolor(_normalize_color(rgb))
604
-
605
- # All bars share the bar call_id
606
- label = bar_call_id or patch.get_label() or f"bar_{i}"
607
-
608
- color_map[key] = {
609
- "id": element_id,
610
- "type": "bar",
611
- "label": label,
612
- "ax_index": ax_idx,
613
- "rgb": list(rgb),
614
- "original_color": _mpl_color_to_hex(
615
- original_props[key]["facecolor"]
616
- ),
617
- "call_id": bar_call_id, # All bars share this call_id
618
- }
619
- element_id += 1
620
-
621
- # Process images
622
- for i, img in enumerate(ax.images):
623
- if isinstance(img, AxesImage):
624
- if not img.get_visible():
625
- continue
626
-
627
- key = f"ax{ax_idx}_image{i}"
628
- # Images can't be recolored, just track their bbox
629
- color_map[key] = {
630
- "id": element_id,
631
- "type": "image",
632
- "label": f"image_{i}",
633
- "ax_index": ax_idx,
634
- "rgb": list(id_to_rgb(element_id)),
635
- }
636
- element_id += 1
637
-
638
- # Process text elements
639
- if include_text:
640
- # Title
641
- title = ax.get_title()
642
- if title:
643
- key = f"ax{ax_idx}_title"
644
- rgb = id_to_rgb(element_id)
645
- title_obj = ax.title
646
-
647
- original_props[key] = {"color": title_obj.get_color()}
648
- title_obj.set_color(_normalize_color(rgb))
649
-
650
- color_map[key] = {
651
- "id": element_id,
652
- "type": "title",
653
- "label": "title",
654
- "ax_index": ax_idx,
655
- "rgb": list(rgb),
656
- }
657
- element_id += 1
658
-
659
- # X label
660
- xlabel = ax.get_xlabel()
661
- if xlabel:
662
- key = f"ax{ax_idx}_xlabel"
663
- rgb = id_to_rgb(element_id)
664
- xlabel_obj = ax.xaxis.label
665
-
666
- original_props[key] = {"color": xlabel_obj.get_color()}
667
- xlabel_obj.set_color(_normalize_color(rgb))
668
-
669
- color_map[key] = {
670
- "id": element_id,
671
- "type": "xlabel",
672
- "label": "xlabel",
673
- "ax_index": ax_idx,
674
- "rgb": list(rgb),
675
- }
676
- element_id += 1
677
-
678
- # Y label
679
- ylabel = ax.get_ylabel()
680
- if ylabel:
681
- key = f"ax{ax_idx}_ylabel"
682
- rgb = id_to_rgb(element_id)
683
- ylabel_obj = ax.yaxis.label
684
-
685
- original_props[key] = {"color": ylabel_obj.get_color()}
686
- ylabel_obj.set_color(_normalize_color(rgb))
687
-
688
- color_map[key] = {
689
- "id": element_id,
690
- "type": "ylabel",
691
- "label": "ylabel",
692
- "ax_index": ax_idx,
693
- "rgb": list(rgb),
694
- }
695
- element_id += 1
696
-
697
- # Process legend
698
- legend = ax.get_legend()
699
- if legend is not None and legend.get_visible():
700
- key = f"ax{ax_idx}_legend"
701
- rgb = id_to_rgb(element_id)
702
-
703
- # Store original frame color
704
- frame = legend.get_frame()
705
- original_props[key] = {
706
- "facecolor": frame.get_facecolor(),
707
- "edgecolor": frame.get_edgecolor(),
708
- }
709
-
710
- frame.set_facecolor(_normalize_color(rgb))
711
- frame.set_edgecolor(_normalize_color(rgb))
712
-
713
- color_map[key] = {
714
- "id": element_id,
715
- "type": "legend",
716
- "label": "legend",
717
- "ax_index": ax_idx,
718
- "rgb": list(rgb),
719
- }
720
- element_id += 1
721
-
722
- # Process figure-level text elements (suptitle, supxlabel, supylabel)
723
- if include_text:
724
- # Suptitle
725
- if hasattr(mpl_fig, "_suptitle") and mpl_fig._suptitle is not None:
726
- suptitle_obj = mpl_fig._suptitle
727
- if suptitle_obj.get_text():
728
- key = "fig_suptitle"
729
- rgb = id_to_rgb(element_id)
730
-
731
- original_props[key] = {"color": suptitle_obj.get_color()}
732
- suptitle_obj.set_color(_normalize_color(rgb))
733
-
734
- color_map[key] = {
735
- "id": element_id,
736
- "type": "suptitle",
737
- "label": "suptitle",
738
- "ax_index": -1, # Figure-level, not axes-specific
739
- "rgb": list(rgb),
740
- }
741
- element_id += 1
742
-
743
- # Supxlabel
744
- if hasattr(mpl_fig, "_supxlabel") and mpl_fig._supxlabel is not None:
745
- supxlabel_obj = mpl_fig._supxlabel
746
- if supxlabel_obj.get_text():
747
- key = "fig_supxlabel"
748
- rgb = id_to_rgb(element_id)
749
-
750
- original_props[key] = {"color": supxlabel_obj.get_color()}
751
- supxlabel_obj.set_color(_normalize_color(rgb))
752
-
753
- color_map[key] = {
754
- "id": element_id,
755
- "type": "supxlabel",
756
- "label": "supxlabel",
757
- "ax_index": -1, # Figure-level
758
- "rgb": list(rgb),
759
- }
760
- element_id += 1
761
-
762
- # Supylabel
763
- if hasattr(mpl_fig, "_supylabel") and mpl_fig._supylabel is not None:
764
- supylabel_obj = mpl_fig._supylabel
765
- if supylabel_obj.get_text():
766
- key = "fig_supylabel"
767
- rgb = id_to_rgb(element_id)
768
-
769
- original_props[key] = {"color": supylabel_obj.get_color()}
770
- supylabel_obj.set_color(_normalize_color(rgb))
771
-
772
- color_map[key] = {
773
- "id": element_id,
774
- "type": "supylabel",
775
- "label": "supylabel",
776
- "ax_index": -1, # Figure-level
777
- "rgb": list(rgb),
778
- }
779
- element_id += 1
780
-
781
- # Set non-selectable elements to axes color
782
- for ax in axes_list:
783
- # Spines
784
- for spine in ax.spines.values():
785
- spine.set_color(_normalize_color(AXES_COLOR))
786
-
787
- # Tick marks
788
- ax.tick_params(colors=_normalize_color(AXES_COLOR))
789
-
790
- # Set figure background
791
- fig.patch.set_facecolor(_normalize_color(BACKGROUND_COLOR))
792
- for ax in axes_list:
793
- ax.set_facecolor(_normalize_color(BACKGROUND_COLOR))
794
-
795
- # Render to buffer (use bbox_inches='tight' to match preview rendering)
796
- buf = io.BytesIO()
797
- fig.savefig(
798
- buf, format="png", dpi=dpi, facecolor=fig.get_facecolor(), bbox_inches="tight"
799
- )
800
- buf.seek(0)
801
-
802
- # Load as PIL Image
803
- hitmap = Image.open(buf).convert("RGB")
804
-
805
- # Restore original properties
806
- for ax_idx, ax in enumerate(axes_list):
807
- # Restore lines
808
- for i, line in enumerate(ax.get_lines()):
809
- key = f"ax{ax_idx}_line{i}"
810
- if key in original_props:
811
- props = original_props[key]
812
- line.set_color(props["color"])
813
- line.set_markerfacecolor(props["markerfacecolor"])
814
- line.set_markeredgecolor(props["markeredgecolor"])
815
-
816
- # Restore collections
817
- for i, coll in enumerate(ax.collections):
818
- if isinstance(coll, PathCollection):
819
- key = f"ax{ax_idx}_scatter{i}"
820
- if key in original_props:
821
- props = original_props[key]
822
- coll.set_facecolors(props["facecolors"])
823
- coll.set_edgecolors(props["edgecolors"])
824
- elif isinstance(coll, PolyCollection):
825
- key = f"ax{ax_idx}_fill{i}"
826
- if key in original_props:
827
- props = original_props[key]
828
- coll.set_facecolors(props["facecolors"])
829
- coll.set_edgecolors(props["edgecolors"])
830
- elif isinstance(coll, LineCollection):
831
- key = f"ax{ax_idx}_linecoll{i}"
832
- if key in original_props:
833
- props = original_props[key]
834
- if len(props["colors"]) > 0:
835
- coll.set_color(props["colors"])
836
-
837
- # Restore patches
838
- for i, patch in enumerate(ax.patches):
839
- key = f"ax{ax_idx}_bar{i}"
840
- if key in original_props:
841
- props = original_props[key]
842
- patch.set_facecolor(props["facecolor"])
843
- patch.set_edgecolor(props["edgecolor"])
844
-
845
- # Restore text
846
- if include_text:
847
- key = f"ax{ax_idx}_title"
848
- if key in original_props:
849
- ax.title.set_color(original_props[key]["color"])
850
-
851
- key = f"ax{ax_idx}_xlabel"
852
- if key in original_props:
853
- ax.xaxis.label.set_color(original_props[key]["color"])
854
-
855
- key = f"ax{ax_idx}_ylabel"
856
- if key in original_props:
857
- ax.yaxis.label.set_color(original_props[key]["color"])
858
-
859
- # Restore legend
860
- key = f"ax{ax_idx}_legend"
861
- if key in original_props:
862
- legend = ax.get_legend()
863
- if legend:
864
- frame = legend.get_frame()
865
- props = original_props[key]
866
- frame.set_facecolor(props["facecolor"])
867
- frame.set_edgecolor(props["edgecolor"])
868
-
869
- # Restore spines
870
- for spine in ax.spines.values():
871
- spine.set_color("black") # Default
872
-
873
- # Restore tick colors
874
- ax.tick_params(colors="black")
875
-
876
- # Restore figure-level text
877
- if include_text:
878
- key = "fig_suptitle"
879
- if (
880
- key in original_props
881
- and hasattr(mpl_fig, "_suptitle")
882
- and mpl_fig._suptitle
883
- ):
884
- mpl_fig._suptitle.set_color(original_props[key]["color"])
885
-
886
- key = "fig_supxlabel"
887
- if (
888
- key in original_props
889
- and hasattr(mpl_fig, "_supxlabel")
890
- and mpl_fig._supxlabel
891
- ):
892
- mpl_fig._supxlabel.set_color(original_props[key]["color"])
893
-
894
- key = "fig_supylabel"
895
- if (
896
- key in original_props
897
- and hasattr(mpl_fig, "_supylabel")
898
- and mpl_fig._supylabel
899
- ):
900
- mpl_fig._supylabel.set_color(original_props[key]["color"])
901
-
902
- # Restore backgrounds
903
- fig.patch.set_facecolor("white")
904
- for ax in axes_list:
905
- ax.set_facecolor("white")
906
-
907
- return hitmap, color_map
908
-
909
-
910
- def hitmap_to_base64(hitmap: Image.Image) -> str:
911
- """
912
- Convert hitmap image to base64 string.
913
-
914
- Parameters
915
- ----------
916
- hitmap : PIL.Image.Image
917
- Hitmap image.
918
-
919
- Returns
920
- -------
921
- str
922
- Base64-encoded PNG string.
923
- """
924
- import base64
925
-
926
- buf = io.BytesIO()
927
- hitmap.save(buf, format="PNG")
928
- buf.seek(0)
929
- return base64.b64encode(buf.read()).decode("utf-8")
930
-
931
-
932
- __all__ = [
933
- "generate_hitmap",
934
- "hitmap_to_base64",
935
- "id_to_rgb",
936
- "rgb_to_id",
937
- ]