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