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.
- figrecipe/__init__.py +220 -819
- 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 +29 -0
- figrecipe/_dev/_plotters.py +76 -0
- figrecipe/_dev/_run_demos.py +56 -0
- figrecipe/_dev/demo_plotters/__init__.py +64 -0
- 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/bar_categorical/plot_bar.py +25 -0
- figrecipe/_dev/demo_plotters/bar_categorical/plot_barh.py +25 -0
- figrecipe/_dev/demo_plotters/contour_surface/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/contour_surface/plot_contour.py +30 -0
- figrecipe/_dev/demo_plotters/contour_surface/plot_contourf.py +29 -0
- figrecipe/_dev/demo_plotters/contour_surface/plot_tricontour.py +28 -0
- figrecipe/_dev/demo_plotters/contour_surface/plot_tricontourf.py +28 -0
- figrecipe/_dev/demo_plotters/contour_surface/plot_tripcolor.py +29 -0
- figrecipe/_dev/demo_plotters/contour_surface/plot_triplot.py +25 -0
- figrecipe/_dev/demo_plotters/distribution/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/distribution/plot_boxplot.py +24 -0
- figrecipe/_dev/demo_plotters/distribution/plot_ecdf.py +24 -0
- figrecipe/_dev/demo_plotters/distribution/plot_hist.py +24 -0
- figrecipe/_dev/demo_plotters/distribution/plot_hist2d.py +25 -0
- figrecipe/_dev/demo_plotters/distribution/plot_violinplot.py +25 -0
- figrecipe/_dev/demo_plotters/image_matrix/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/image_matrix/plot_hexbin.py +25 -0
- figrecipe/_dev/demo_plotters/image_matrix/plot_imshow.py +23 -0
- figrecipe/_dev/demo_plotters/image_matrix/plot_matshow.py +23 -0
- figrecipe/_dev/demo_plotters/image_matrix/plot_pcolor.py +29 -0
- figrecipe/_dev/demo_plotters/image_matrix/plot_pcolormesh.py +29 -0
- figrecipe/_dev/demo_plotters/image_matrix/plot_spy.py +29 -0
- figrecipe/_dev/demo_plotters/line_curve/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/line_curve/plot_errorbar.py +28 -0
- figrecipe/_dev/demo_plotters/line_curve/plot_fill.py +29 -0
- figrecipe/_dev/demo_plotters/line_curve/plot_fill_between.py +30 -0
- figrecipe/_dev/demo_plotters/line_curve/plot_fill_betweenx.py +28 -0
- figrecipe/_dev/demo_plotters/line_curve/plot_plot.py +28 -0
- figrecipe/_dev/demo_plotters/line_curve/plot_stackplot.py +29 -0
- figrecipe/_dev/demo_plotters/line_curve/plot_stairs.py +27 -0
- figrecipe/_dev/demo_plotters/line_curve/plot_step.py +27 -0
- figrecipe/_dev/demo_plotters/scatter_points/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/scatter_points/plot_scatter.py +24 -0
- figrecipe/_dev/demo_plotters/special/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/special/plot_eventplot.py +25 -0
- figrecipe/_dev/demo_plotters/special/plot_loglog.py +27 -0
- figrecipe/_dev/demo_plotters/special/plot_pie.py +27 -0
- figrecipe/_dev/demo_plotters/special/plot_semilogx.py +27 -0
- figrecipe/_dev/demo_plotters/special/plot_semilogy.py +27 -0
- figrecipe/_dev/demo_plotters/special/plot_stem.py +27 -0
- figrecipe/_dev/demo_plotters/spectral_signal/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/spectral_signal/plot_acorr.py +24 -0
- figrecipe/_dev/demo_plotters/spectral_signal/plot_angle_spectrum.py +28 -0
- figrecipe/_dev/demo_plotters/spectral_signal/plot_cohere.py +29 -0
- figrecipe/_dev/demo_plotters/spectral_signal/plot_csd.py +29 -0
- figrecipe/_dev/demo_plotters/spectral_signal/plot_magnitude_spectrum.py +28 -0
- figrecipe/_dev/demo_plotters/spectral_signal/plot_phase_spectrum.py +28 -0
- figrecipe/_dev/demo_plotters/spectral_signal/plot_psd.py +29 -0
- figrecipe/_dev/demo_plotters/spectral_signal/plot_specgram.py +30 -0
- figrecipe/_dev/demo_plotters/spectral_signal/plot_xcorr.py +25 -0
- figrecipe/_dev/demo_plotters/vector_flow/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/vector_flow/plot_barbs.py +30 -0
- figrecipe/_dev/demo_plotters/vector_flow/plot_quiver.py +30 -0
- figrecipe/_dev/demo_plotters/vector_flow/plot_streamplot.py +30 -0
- figrecipe/_editor/__init__.py +278 -0
- 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 +258 -0
- 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/_overrides.py +318 -0
- figrecipe/_editor/_preferences.py +135 -0
- figrecipe/_editor/_render_overrides.py +480 -0
- figrecipe/_editor/_renderer.py +199 -0
- 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 +152 -0
- figrecipe/_editor/_templates/_html.py +502 -0
- 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 +33 -0
- figrecipe/_params/_PLOTTING_METHODS.py +58 -0
- figrecipe/_params/__init__.py +9 -0
- figrecipe/_recorder.py +92 -110
- 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/_seaborn.py +14 -9
- figrecipe/_serializer.py +2 -2
- figrecipe/_signatures/README.md +68 -0
- figrecipe/_signatures/__init__.py +12 -2
- figrecipe/_signatures/_kwargs.py +273 -0
- figrecipe/_signatures/_loader.py +114 -57
- figrecipe/_signatures/_parsing.py +147 -0
- figrecipe/_utils/__init__.py +6 -4
- figrecipe/_utils/_crop.py +10 -4
- figrecipe/_utils/_image_diff.py +37 -33
- figrecipe/_utils/_numpy_io.py +0 -1
- figrecipe/_utils/_units.py +11 -3
- figrecipe/_validator.py +12 -3
- figrecipe/_wrappers/_axes.py +193 -170
- 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 +277 -18
- figrecipe/_wrappers/_panel_labels.py +127 -0
- figrecipe/_wrappers/_plot_helpers.py +143 -0
- figrecipe/_wrappers/_violin_helpers.py +180 -0
- figrecipe/plt.py +0 -1
- figrecipe/pyplot.py +2 -1
- figrecipe/styles/__init__.py +12 -11
- 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 +60 -202
- figrecipe/styles/_style_loader.py +73 -121
- figrecipe/styles/_themes.py +151 -0
- figrecipe/styles/presets/MATPLOTLIB.yaml +95 -0
- figrecipe/styles/presets/SCITEX.yaml +181 -0
- figrecipe-0.7.4.dist-info/METADATA +429 -0
- figrecipe-0.7.4.dist-info/RECORD +188 -0
- figrecipe/_reproducer.py +0 -358
- figrecipe-0.5.0.dist-info/METADATA +0 -336
- figrecipe-0.5.0.dist-info/RECORD +0 -26
- {figrecipe-0.5.0.dist-info → figrecipe-0.7.4.dist-info}/WHEEL +0 -0
- {figrecipe-0.5.0.dist-info → figrecipe-0.7.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Line bbox extraction for line plots.
|
|
5
|
+
|
|
6
|
+
This module handles bbox extraction for line elements,
|
|
7
|
+
including data point sampling for hit detection.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
|
|
12
|
+
from matplotlib.axes import Axes
|
|
13
|
+
from matplotlib.figure import Figure
|
|
14
|
+
from matplotlib.transforms import Bbox
|
|
15
|
+
|
|
16
|
+
from ._transforms import display_to_image, transform_bbox
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_line_bbox(
|
|
20
|
+
line,
|
|
21
|
+
ax: Axes,
|
|
22
|
+
fig: Figure,
|
|
23
|
+
renderer,
|
|
24
|
+
tight_bbox: Bbox,
|
|
25
|
+
img_width: int,
|
|
26
|
+
img_height: int,
|
|
27
|
+
scale_x: float,
|
|
28
|
+
scale_y: float,
|
|
29
|
+
pad_inches: float,
|
|
30
|
+
saved_height_inches: float,
|
|
31
|
+
include_points: bool = True,
|
|
32
|
+
) -> Optional[Dict[str, Any]]:
|
|
33
|
+
"""Get bbox and points for a line."""
|
|
34
|
+
try:
|
|
35
|
+
# Get window extent
|
|
36
|
+
window_extent = line.get_window_extent(renderer)
|
|
37
|
+
if window_extent is None:
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
bbox = transform_bbox(
|
|
41
|
+
window_extent,
|
|
42
|
+
fig,
|
|
43
|
+
tight_bbox,
|
|
44
|
+
img_width,
|
|
45
|
+
img_height,
|
|
46
|
+
scale_x,
|
|
47
|
+
scale_y,
|
|
48
|
+
pad_inches,
|
|
49
|
+
saved_height_inches,
|
|
50
|
+
)
|
|
51
|
+
if bbox is None:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
# Add path points for proximity detection
|
|
55
|
+
if include_points:
|
|
56
|
+
xdata = line.get_xdata()
|
|
57
|
+
ydata = line.get_ydata()
|
|
58
|
+
|
|
59
|
+
if len(xdata) > 0 and len(ydata) > 0:
|
|
60
|
+
# Transform data coords to image pixels
|
|
61
|
+
transform = ax.transData
|
|
62
|
+
points = []
|
|
63
|
+
|
|
64
|
+
# Downsample if too many points
|
|
65
|
+
max_points = 100
|
|
66
|
+
step = max(1, len(xdata) // max_points)
|
|
67
|
+
|
|
68
|
+
for i in range(0, len(xdata), step):
|
|
69
|
+
try:
|
|
70
|
+
display_coords = transform.transform((xdata[i], ydata[i]))
|
|
71
|
+
img_coords = display_to_image(
|
|
72
|
+
display_coords[0],
|
|
73
|
+
display_coords[1],
|
|
74
|
+
fig,
|
|
75
|
+
tight_bbox,
|
|
76
|
+
img_width,
|
|
77
|
+
img_height,
|
|
78
|
+
scale_x,
|
|
79
|
+
scale_y,
|
|
80
|
+
pad_inches,
|
|
81
|
+
saved_height_inches,
|
|
82
|
+
)
|
|
83
|
+
if img_coords:
|
|
84
|
+
points.append(img_coords)
|
|
85
|
+
except Exception:
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
if points:
|
|
89
|
+
bbox["points"] = points
|
|
90
|
+
|
|
91
|
+
return bbox
|
|
92
|
+
|
|
93
|
+
except Exception:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_quiver_bbox(
|
|
98
|
+
quiver,
|
|
99
|
+
ax: Axes,
|
|
100
|
+
fig: Figure,
|
|
101
|
+
renderer,
|
|
102
|
+
tight_bbox: Bbox,
|
|
103
|
+
img_width: int,
|
|
104
|
+
img_height: int,
|
|
105
|
+
scale_x: float,
|
|
106
|
+
scale_y: float,
|
|
107
|
+
pad_inches: float,
|
|
108
|
+
saved_height_inches: float,
|
|
109
|
+
) -> Optional[Dict[str, Any]]:
|
|
110
|
+
"""Get bbox for a quiver plot from its data points."""
|
|
111
|
+
try:
|
|
112
|
+
# Get X, Y positions from quiver
|
|
113
|
+
# Quiver stores positions in X, Y arrays
|
|
114
|
+
X = quiver.X
|
|
115
|
+
Y = quiver.Y
|
|
116
|
+
|
|
117
|
+
if X is None or Y is None or len(X) == 0:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
# Flatten if needed
|
|
121
|
+
import numpy as np
|
|
122
|
+
|
|
123
|
+
X_flat = np.asarray(X).flatten()
|
|
124
|
+
Y_flat = np.asarray(Y).flatten()
|
|
125
|
+
|
|
126
|
+
if len(X_flat) == 0 or len(Y_flat) == 0:
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
transform = ax.transData
|
|
130
|
+
points = []
|
|
131
|
+
|
|
132
|
+
# Limit to reasonable number of points
|
|
133
|
+
max_points = 100
|
|
134
|
+
step = max(1, len(X_flat) // max_points)
|
|
135
|
+
|
|
136
|
+
for i in range(0, len(X_flat), step):
|
|
137
|
+
try:
|
|
138
|
+
display_coords = transform.transform((X_flat[i], Y_flat[i]))
|
|
139
|
+
img_coords = display_to_image(
|
|
140
|
+
display_coords[0],
|
|
141
|
+
display_coords[1],
|
|
142
|
+
fig,
|
|
143
|
+
tight_bbox,
|
|
144
|
+
img_width,
|
|
145
|
+
img_height,
|
|
146
|
+
scale_x,
|
|
147
|
+
scale_y,
|
|
148
|
+
pad_inches,
|
|
149
|
+
saved_height_inches,
|
|
150
|
+
)
|
|
151
|
+
if img_coords:
|
|
152
|
+
points.append(img_coords)
|
|
153
|
+
except Exception:
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
if not points:
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
# Calculate bbox from points
|
|
160
|
+
xs = [p[0] for p in points]
|
|
161
|
+
ys = [p[1] for p in points]
|
|
162
|
+
padding = 15 # pixels - slightly larger for quiver arrows
|
|
163
|
+
return {
|
|
164
|
+
"x": float(min(xs) - padding),
|
|
165
|
+
"y": float(min(ys) - padding),
|
|
166
|
+
"width": float(max(xs) - min(xs) + 2 * padding),
|
|
167
|
+
"height": float(max(ys) - min(ys) + 2 * padding),
|
|
168
|
+
}
|
|
169
|
+
except Exception:
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
__all__ = ["get_line_bbox", "get_quiver_bbox"]
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Coordinate transformation utilities for bbox extraction.
|
|
5
|
+
|
|
6
|
+
This module handles the transformation from matplotlib display coordinates
|
|
7
|
+
to image pixel coordinates for hit detection.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
from matplotlib.figure import Figure
|
|
13
|
+
from matplotlib.transforms import Bbox
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def transform_bbox(
|
|
17
|
+
window_extent: Bbox,
|
|
18
|
+
fig: Figure,
|
|
19
|
+
tight_bbox: Bbox,
|
|
20
|
+
img_width: int,
|
|
21
|
+
img_height: int,
|
|
22
|
+
scale_x: float,
|
|
23
|
+
scale_y: float,
|
|
24
|
+
pad_inches: float,
|
|
25
|
+
saved_height_inches: float,
|
|
26
|
+
) -> Optional[Dict[str, float]]:
|
|
27
|
+
"""
|
|
28
|
+
Transform matplotlib window extent to image pixel coordinates.
|
|
29
|
+
|
|
30
|
+
Parameters
|
|
31
|
+
----------
|
|
32
|
+
window_extent : Bbox
|
|
33
|
+
Bbox in display coordinates (points).
|
|
34
|
+
fig : Figure
|
|
35
|
+
Matplotlib figure.
|
|
36
|
+
tight_bbox : Bbox
|
|
37
|
+
Tight bbox of figure in inches.
|
|
38
|
+
img_width, img_height : int
|
|
39
|
+
Output image dimensions.
|
|
40
|
+
scale_x, scale_y : float
|
|
41
|
+
Scale factors from inches to pixels.
|
|
42
|
+
pad_inches : float
|
|
43
|
+
Padding added by bbox_inches='tight' (default 0.1).
|
|
44
|
+
saved_height_inches : float
|
|
45
|
+
Total saved image height including padding.
|
|
46
|
+
|
|
47
|
+
Returns
|
|
48
|
+
-------
|
|
49
|
+
dict or None
|
|
50
|
+
{x, y, width, height} in image pixels.
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
dpi = fig.dpi
|
|
54
|
+
|
|
55
|
+
# Convert display coords to inches
|
|
56
|
+
x0_inches = window_extent.x0 / dpi
|
|
57
|
+
y0_inches = window_extent.y0 / dpi
|
|
58
|
+
x1_inches = window_extent.x1 / dpi
|
|
59
|
+
y1_inches = window_extent.y1 / dpi
|
|
60
|
+
|
|
61
|
+
# Transform to saved image coordinates
|
|
62
|
+
# Account for tight bbox origin and padding
|
|
63
|
+
x0_rel = x0_inches - tight_bbox.x0 + pad_inches
|
|
64
|
+
x1_rel = x1_inches - tight_bbox.x0 + pad_inches
|
|
65
|
+
|
|
66
|
+
# Y coordinate flip: matplotlib Y=0 at bottom, image Y=0 at top
|
|
67
|
+
y0_rel = saved_height_inches - (y1_inches - tight_bbox.y0 + pad_inches)
|
|
68
|
+
y1_rel = saved_height_inches - (y0_inches - tight_bbox.y0 + pad_inches)
|
|
69
|
+
|
|
70
|
+
# Scale to image pixels
|
|
71
|
+
x0_px = x0_rel * scale_x
|
|
72
|
+
y0_px = y0_rel * scale_y
|
|
73
|
+
x1_px = x1_rel * scale_x
|
|
74
|
+
y1_px = y1_rel * scale_y
|
|
75
|
+
|
|
76
|
+
# Clamp to bounds
|
|
77
|
+
x0_px = max(0, min(x0_px, img_width))
|
|
78
|
+
x1_px = max(0, min(x1_px, img_width))
|
|
79
|
+
y0_px = max(0, min(y0_px, img_height))
|
|
80
|
+
y1_px = max(0, min(y1_px, img_height))
|
|
81
|
+
|
|
82
|
+
width = x1_px - x0_px
|
|
83
|
+
height = y1_px - y0_px
|
|
84
|
+
|
|
85
|
+
if width <= 0 or height <= 0:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
"x": float(x0_px),
|
|
90
|
+
"y": float(y0_px),
|
|
91
|
+
"width": float(width),
|
|
92
|
+
"height": float(height),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
except Exception:
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def display_to_image(
|
|
100
|
+
display_x: float,
|
|
101
|
+
display_y: float,
|
|
102
|
+
fig: Figure,
|
|
103
|
+
tight_bbox: Bbox,
|
|
104
|
+
img_width: int,
|
|
105
|
+
img_height: int,
|
|
106
|
+
scale_x: float,
|
|
107
|
+
scale_y: float,
|
|
108
|
+
pad_inches: float,
|
|
109
|
+
saved_height_inches: float,
|
|
110
|
+
) -> Optional[List[float]]:
|
|
111
|
+
"""
|
|
112
|
+
Transform display coordinates to image pixel coordinates.
|
|
113
|
+
|
|
114
|
+
Returns
|
|
115
|
+
-------
|
|
116
|
+
list or None
|
|
117
|
+
[x, y] in image pixels.
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
dpi = fig.dpi
|
|
121
|
+
|
|
122
|
+
# Convert to inches
|
|
123
|
+
x_inches = display_x / dpi
|
|
124
|
+
y_inches = display_y / dpi
|
|
125
|
+
|
|
126
|
+
# Transform to saved image coordinates with padding
|
|
127
|
+
x_rel = x_inches - tight_bbox.x0 + pad_inches
|
|
128
|
+
|
|
129
|
+
# Y coordinate flip: matplotlib Y=0 at bottom, image Y=0 at top
|
|
130
|
+
y_rel = saved_height_inches - (y_inches - tight_bbox.y0 + pad_inches)
|
|
131
|
+
|
|
132
|
+
# Scale to image pixels
|
|
133
|
+
x_px = x_rel * scale_x
|
|
134
|
+
y_px = y_rel * scale_y
|
|
135
|
+
|
|
136
|
+
# Clamp
|
|
137
|
+
x_px = max(0, min(x_px, img_width))
|
|
138
|
+
y_px = max(0, min(y_px, img_height))
|
|
139
|
+
|
|
140
|
+
return [float(x_px), float(y_px)]
|
|
141
|
+
|
|
142
|
+
except Exception:
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
__all__ = ["transform_bbox", "display_to_image"]
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Flask-based GUI editor for figure styling.
|
|
5
|
+
|
|
6
|
+
This module provides a web-based interface for interactively adjusting
|
|
7
|
+
figure styles with hitmap-based element selection.
|
|
8
|
+
|
|
9
|
+
Style Override Architecture
|
|
10
|
+
---------------------------
|
|
11
|
+
Styles are managed in layers with separate storage:
|
|
12
|
+
|
|
13
|
+
1. Base style (from preset like SCITEX)
|
|
14
|
+
2. Programmatic style (from code)
|
|
15
|
+
3. Manual overrides (from GUI editor)
|
|
16
|
+
|
|
17
|
+
Manual overrides are stored separately in `.overrides.json` files,
|
|
18
|
+
allowing restoration to original programmatic styles.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
# Force Agg backend before any pyplot import to avoid Tkinter threading issues
|
|
22
|
+
import matplotlib
|
|
23
|
+
|
|
24
|
+
matplotlib.use("Agg")
|
|
25
|
+
|
|
26
|
+
import webbrowser
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Dict, Optional
|
|
29
|
+
|
|
30
|
+
from .._wrappers import RecordingFigure
|
|
31
|
+
from ._overrides import create_overrides_from_style, load_overrides
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FigureEditor:
|
|
35
|
+
"""
|
|
36
|
+
Browser-based figure style editor using Flask.
|
|
37
|
+
|
|
38
|
+
Features:
|
|
39
|
+
- Real-time figure preview with style overrides
|
|
40
|
+
- Hitmap-based element selection
|
|
41
|
+
- Full style property editing (dimensions, fonts, lines, colors, etc.)
|
|
42
|
+
- Dark/light theme toggle
|
|
43
|
+
- Download in PNG/SVG/PDF formats
|
|
44
|
+
- Separate storage of manual overrides (can restore to original)
|
|
45
|
+
- Hot reload: server restarts on source file changes (like Django)
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
fig: RecordingFigure,
|
|
51
|
+
recipe_path: Optional[Path] = None,
|
|
52
|
+
style: Optional[Dict[str, Any]] = None,
|
|
53
|
+
port: int = 5050,
|
|
54
|
+
static_png_path: Optional[Path] = None,
|
|
55
|
+
hitmap_base64: Optional[str] = None,
|
|
56
|
+
color_map: Optional[Dict] = None,
|
|
57
|
+
hot_reload: bool = False,
|
|
58
|
+
working_dir: Optional[Path] = None,
|
|
59
|
+
):
|
|
60
|
+
"""
|
|
61
|
+
Initialize figure editor.
|
|
62
|
+
|
|
63
|
+
Parameters
|
|
64
|
+
----------
|
|
65
|
+
fig : RecordingFigure
|
|
66
|
+
Figure to edit.
|
|
67
|
+
recipe_path : Path, optional
|
|
68
|
+
Path to recipe file (if loaded from file).
|
|
69
|
+
style : dict, optional
|
|
70
|
+
Initial style configuration (programmatic).
|
|
71
|
+
port : int
|
|
72
|
+
Flask server port.
|
|
73
|
+
static_png_path : Path, optional
|
|
74
|
+
Path to pre-rendered static PNG (source of truth for initial display).
|
|
75
|
+
hitmap_base64 : str, optional
|
|
76
|
+
Pre-generated hitmap as base64.
|
|
77
|
+
color_map : dict, optional
|
|
78
|
+
Pre-generated color map for hitmap.
|
|
79
|
+
hot_reload : bool, optional
|
|
80
|
+
Enable hot reload - server restarts on source file changes.
|
|
81
|
+
working_dir : Path, optional
|
|
82
|
+
Working directory for file switching (default: current directory).
|
|
83
|
+
"""
|
|
84
|
+
self.fig = fig
|
|
85
|
+
self.recipe_path = Path(recipe_path) if recipe_path else None
|
|
86
|
+
self.port = port
|
|
87
|
+
self.hot_reload = hot_reload
|
|
88
|
+
self.working_dir = Path(working_dir) if working_dir else Path.cwd()
|
|
89
|
+
|
|
90
|
+
# Load user preferences
|
|
91
|
+
from ._preferences import load_preferences
|
|
92
|
+
|
|
93
|
+
prefs = load_preferences()
|
|
94
|
+
self.dark_mode = prefs.get("dark_mode", False)
|
|
95
|
+
|
|
96
|
+
# Pre-rendered static PNG (source of truth)
|
|
97
|
+
self._static_png_path = static_png_path
|
|
98
|
+
self._initial_base64 = None
|
|
99
|
+
if static_png_path and static_png_path.exists():
|
|
100
|
+
import base64
|
|
101
|
+
|
|
102
|
+
with open(static_png_path, "rb") as f:
|
|
103
|
+
self._initial_base64 = base64.b64encode(f.read()).decode("utf-8")
|
|
104
|
+
|
|
105
|
+
# Store original axes positions for restore functionality
|
|
106
|
+
self._initial_axes_positions = self._capture_axes_positions()
|
|
107
|
+
|
|
108
|
+
# Initialize style overrides system
|
|
109
|
+
self._init_style_overrides(style)
|
|
110
|
+
|
|
111
|
+
# Pre-generated hitmap and color_map
|
|
112
|
+
# Use empty dict as default to prevent JavaScript errors
|
|
113
|
+
# when page loads before hitmap is generated
|
|
114
|
+
self._hitmap_base64 = hitmap_base64
|
|
115
|
+
self._color_map = color_map if color_map is not None else {}
|
|
116
|
+
|
|
117
|
+
def _init_style_overrides(self, programmatic_style: Optional[Dict[str, Any]]):
|
|
118
|
+
"""Initialize the layered style override system."""
|
|
119
|
+
# Try to load existing overrides
|
|
120
|
+
if self.recipe_path:
|
|
121
|
+
existing = load_overrides(self.recipe_path)
|
|
122
|
+
if existing:
|
|
123
|
+
self.style_overrides = existing
|
|
124
|
+
if programmatic_style:
|
|
125
|
+
self.style_overrides.programmatic_style = programmatic_style
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
# Get base style from global preset
|
|
129
|
+
base_style = {}
|
|
130
|
+
style_name = "SCITEX"
|
|
131
|
+
try:
|
|
132
|
+
from ..styles._style_loader import (
|
|
133
|
+
_CURRENT_STYLE_NAME,
|
|
134
|
+
_STYLE_CACHE,
|
|
135
|
+
load_style,
|
|
136
|
+
to_subplots_kwargs,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if _STYLE_CACHE is None:
|
|
140
|
+
load_style("SCITEX")
|
|
141
|
+
|
|
142
|
+
from ..styles._style_loader import _STYLE_CACHE
|
|
143
|
+
|
|
144
|
+
if _STYLE_CACHE is not None:
|
|
145
|
+
base_style = to_subplots_kwargs(_STYLE_CACHE)
|
|
146
|
+
style_name = _CURRENT_STYLE_NAME or "SCITEX"
|
|
147
|
+
except Exception:
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
self._style_name = style_name
|
|
151
|
+
|
|
152
|
+
self.style_overrides = create_overrides_from_style(
|
|
153
|
+
base_style=base_style,
|
|
154
|
+
programmatic_style=programmatic_style or {},
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def style(self) -> Dict[str, Any]:
|
|
159
|
+
"""Get the original style (without manual overrides)."""
|
|
160
|
+
return self.style_overrides.get_original_style()
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def overrides(self) -> Dict[str, Any]:
|
|
164
|
+
"""Get current manual overrides."""
|
|
165
|
+
return self.style_overrides.manual_overrides
|
|
166
|
+
|
|
167
|
+
@overrides.setter
|
|
168
|
+
def overrides(self, value: Dict[str, Any]):
|
|
169
|
+
"""Set manual overrides."""
|
|
170
|
+
self.style_overrides.manual_overrides = value
|
|
171
|
+
|
|
172
|
+
def get_effective_style(self) -> Dict[str, Any]:
|
|
173
|
+
"""Get the final merged style."""
|
|
174
|
+
return self.style_overrides.get_effective_style()
|
|
175
|
+
|
|
176
|
+
def _capture_axes_positions(self) -> Dict[int, list]:
|
|
177
|
+
"""Capture current axes positions (matplotlib coords: [left, bottom, width, height])."""
|
|
178
|
+
mpl_fig = self.fig.fig if hasattr(self.fig, "fig") else self.fig
|
|
179
|
+
axes = mpl_fig.get_axes()
|
|
180
|
+
positions = {}
|
|
181
|
+
for i, ax in enumerate(axes):
|
|
182
|
+
bbox = ax.get_position()
|
|
183
|
+
positions[i] = [bbox.x0, bbox.y0, bbox.width, bbox.height]
|
|
184
|
+
return positions
|
|
185
|
+
|
|
186
|
+
def restore_axes_positions(self) -> None:
|
|
187
|
+
"""Restore axes to their original positions."""
|
|
188
|
+
if not self._initial_axes_positions:
|
|
189
|
+
return
|
|
190
|
+
mpl_fig = self.fig.fig if hasattr(self.fig, "fig") else self.fig
|
|
191
|
+
axes = mpl_fig.get_axes()
|
|
192
|
+
for i, ax in enumerate(axes):
|
|
193
|
+
if i in self._initial_axes_positions:
|
|
194
|
+
pos = self._initial_axes_positions[i]
|
|
195
|
+
ax.set_position(pos)
|
|
196
|
+
|
|
197
|
+
def run(self, open_browser: bool = True) -> Dict[str, Any]:
|
|
198
|
+
"""
|
|
199
|
+
Run the editor server.
|
|
200
|
+
|
|
201
|
+
Parameters
|
|
202
|
+
----------
|
|
203
|
+
open_browser : bool
|
|
204
|
+
Whether to open browser automatically.
|
|
205
|
+
|
|
206
|
+
Returns
|
|
207
|
+
-------
|
|
208
|
+
dict
|
|
209
|
+
Final style overrides after editing session.
|
|
210
|
+
"""
|
|
211
|
+
from flask import Flask
|
|
212
|
+
|
|
213
|
+
from ._routes_axis import register_axis_routes
|
|
214
|
+
from ._routes_core import register_core_routes
|
|
215
|
+
from ._routes_element import register_element_routes
|
|
216
|
+
from ._routes_style import register_style_routes
|
|
217
|
+
|
|
218
|
+
# Defer hitmap generation until first request (lazy loading)
|
|
219
|
+
self._hitmap_generated = self._hitmap_base64 is not None
|
|
220
|
+
|
|
221
|
+
# Create Flask app
|
|
222
|
+
app = Flask(__name__)
|
|
223
|
+
|
|
224
|
+
# Register all routes
|
|
225
|
+
register_core_routes(app, self)
|
|
226
|
+
register_style_routes(app, self)
|
|
227
|
+
register_axis_routes(app, self)
|
|
228
|
+
register_element_routes(app, self)
|
|
229
|
+
|
|
230
|
+
# Start server
|
|
231
|
+
url = f"http://127.0.0.1:{self.port}"
|
|
232
|
+
print(f"Figure Editor running at {url}")
|
|
233
|
+
|
|
234
|
+
if self.hot_reload:
|
|
235
|
+
print("Hot reload ENABLED - server will restart on source file changes")
|
|
236
|
+
print("Press Ctrl+C to stop and return overrides")
|
|
237
|
+
|
|
238
|
+
if open_browser:
|
|
239
|
+
webbrowser.open(url)
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
# Use Flask's built-in reloader when hot_reload is enabled
|
|
243
|
+
# Note: debug and use_reloader are always False when working with
|
|
244
|
+
# multiple coding agents to avoid file watching conflicts
|
|
245
|
+
app.run(
|
|
246
|
+
host="127.0.0.1",
|
|
247
|
+
port=self.port,
|
|
248
|
+
debug=False,
|
|
249
|
+
use_reloader=False,
|
|
250
|
+
threaded=True,
|
|
251
|
+
)
|
|
252
|
+
except KeyboardInterrupt:
|
|
253
|
+
print("\nEditor closed")
|
|
254
|
+
|
|
255
|
+
return self.overrides
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
__all__ = ["FigureEditor"]
|