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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. figrecipe/__init__.py +220 -819
  2. figrecipe/_api/__init__.py +48 -0
  3. figrecipe/_api/_extract.py +108 -0
  4. figrecipe/_api/_notebook.py +61 -0
  5. figrecipe/_api/_panel.py +46 -0
  6. figrecipe/_api/_save.py +191 -0
  7. figrecipe/_api/_seaborn_proxy.py +34 -0
  8. figrecipe/_api/_style_manager.py +153 -0
  9. figrecipe/_api/_subplots.py +333 -0
  10. figrecipe/_api/_validate.py +82 -0
  11. figrecipe/_dev/__init__.py +29 -0
  12. figrecipe/_dev/_plotters.py +76 -0
  13. figrecipe/_dev/_run_demos.py +56 -0
  14. figrecipe/_dev/demo_plotters/__init__.py +64 -0
  15. figrecipe/_dev/demo_plotters/_categories.py +81 -0
  16. figrecipe/_dev/demo_plotters/_figure_creators.py +119 -0
  17. figrecipe/_dev/demo_plotters/_helpers.py +31 -0
  18. figrecipe/_dev/demo_plotters/_registry.py +50 -0
  19. figrecipe/_dev/demo_plotters/bar_categorical/__init__.py +4 -0
  20. figrecipe/_dev/demo_plotters/bar_categorical/plot_bar.py +25 -0
  21. figrecipe/_dev/demo_plotters/bar_categorical/plot_barh.py +25 -0
  22. figrecipe/_dev/demo_plotters/contour_surface/__init__.py +4 -0
  23. figrecipe/_dev/demo_plotters/contour_surface/plot_contour.py +30 -0
  24. figrecipe/_dev/demo_plotters/contour_surface/plot_contourf.py +29 -0
  25. figrecipe/_dev/demo_plotters/contour_surface/plot_tricontour.py +28 -0
  26. figrecipe/_dev/demo_plotters/contour_surface/plot_tricontourf.py +28 -0
  27. figrecipe/_dev/demo_plotters/contour_surface/plot_tripcolor.py +29 -0
  28. figrecipe/_dev/demo_plotters/contour_surface/plot_triplot.py +25 -0
  29. figrecipe/_dev/demo_plotters/distribution/__init__.py +4 -0
  30. figrecipe/_dev/demo_plotters/distribution/plot_boxplot.py +24 -0
  31. figrecipe/_dev/demo_plotters/distribution/plot_ecdf.py +24 -0
  32. figrecipe/_dev/demo_plotters/distribution/plot_hist.py +24 -0
  33. figrecipe/_dev/demo_plotters/distribution/plot_hist2d.py +25 -0
  34. figrecipe/_dev/demo_plotters/distribution/plot_violinplot.py +25 -0
  35. figrecipe/_dev/demo_plotters/image_matrix/__init__.py +4 -0
  36. figrecipe/_dev/demo_plotters/image_matrix/plot_hexbin.py +25 -0
  37. figrecipe/_dev/demo_plotters/image_matrix/plot_imshow.py +23 -0
  38. figrecipe/_dev/demo_plotters/image_matrix/plot_matshow.py +23 -0
  39. figrecipe/_dev/demo_plotters/image_matrix/plot_pcolor.py +29 -0
  40. figrecipe/_dev/demo_plotters/image_matrix/plot_pcolormesh.py +29 -0
  41. figrecipe/_dev/demo_plotters/image_matrix/plot_spy.py +29 -0
  42. figrecipe/_dev/demo_plotters/line_curve/__init__.py +4 -0
  43. figrecipe/_dev/demo_plotters/line_curve/plot_errorbar.py +28 -0
  44. figrecipe/_dev/demo_plotters/line_curve/plot_fill.py +29 -0
  45. figrecipe/_dev/demo_plotters/line_curve/plot_fill_between.py +30 -0
  46. figrecipe/_dev/demo_plotters/line_curve/plot_fill_betweenx.py +28 -0
  47. figrecipe/_dev/demo_plotters/line_curve/plot_plot.py +28 -0
  48. figrecipe/_dev/demo_plotters/line_curve/plot_stackplot.py +29 -0
  49. figrecipe/_dev/demo_plotters/line_curve/plot_stairs.py +27 -0
  50. figrecipe/_dev/demo_plotters/line_curve/plot_step.py +27 -0
  51. figrecipe/_dev/demo_plotters/scatter_points/__init__.py +4 -0
  52. figrecipe/_dev/demo_plotters/scatter_points/plot_scatter.py +24 -0
  53. figrecipe/_dev/demo_plotters/special/__init__.py +4 -0
  54. figrecipe/_dev/demo_plotters/special/plot_eventplot.py +25 -0
  55. figrecipe/_dev/demo_plotters/special/plot_loglog.py +27 -0
  56. figrecipe/_dev/demo_plotters/special/plot_pie.py +27 -0
  57. figrecipe/_dev/demo_plotters/special/plot_semilogx.py +27 -0
  58. figrecipe/_dev/demo_plotters/special/plot_semilogy.py +27 -0
  59. figrecipe/_dev/demo_plotters/special/plot_stem.py +27 -0
  60. figrecipe/_dev/demo_plotters/spectral_signal/__init__.py +4 -0
  61. figrecipe/_dev/demo_plotters/spectral_signal/plot_acorr.py +24 -0
  62. figrecipe/_dev/demo_plotters/spectral_signal/plot_angle_spectrum.py +28 -0
  63. figrecipe/_dev/demo_plotters/spectral_signal/plot_cohere.py +29 -0
  64. figrecipe/_dev/demo_plotters/spectral_signal/plot_csd.py +29 -0
  65. figrecipe/_dev/demo_plotters/spectral_signal/plot_magnitude_spectrum.py +28 -0
  66. figrecipe/_dev/demo_plotters/spectral_signal/plot_phase_spectrum.py +28 -0
  67. figrecipe/_dev/demo_plotters/spectral_signal/plot_psd.py +29 -0
  68. figrecipe/_dev/demo_plotters/spectral_signal/plot_specgram.py +30 -0
  69. figrecipe/_dev/demo_plotters/spectral_signal/plot_xcorr.py +25 -0
  70. figrecipe/_dev/demo_plotters/vector_flow/__init__.py +4 -0
  71. figrecipe/_dev/demo_plotters/vector_flow/plot_barbs.py +30 -0
  72. figrecipe/_dev/demo_plotters/vector_flow/plot_quiver.py +30 -0
  73. figrecipe/_dev/demo_plotters/vector_flow/plot_streamplot.py +30 -0
  74. figrecipe/_editor/__init__.py +278 -0
  75. figrecipe/_editor/_bbox/__init__.py +43 -0
  76. figrecipe/_editor/_bbox/_collections.py +177 -0
  77. figrecipe/_editor/_bbox/_elements.py +159 -0
  78. figrecipe/_editor/_bbox/_extract.py +256 -0
  79. figrecipe/_editor/_bbox/_extract_axes.py +370 -0
  80. figrecipe/_editor/_bbox/_extract_text.py +342 -0
  81. figrecipe/_editor/_bbox/_lines.py +173 -0
  82. figrecipe/_editor/_bbox/_transforms.py +146 -0
  83. figrecipe/_editor/_flask_app.py +258 -0
  84. figrecipe/_editor/_helpers.py +242 -0
  85. figrecipe/_editor/_hitmap/__init__.py +76 -0
  86. figrecipe/_editor/_hitmap/_artists/__init__.py +21 -0
  87. figrecipe/_editor/_hitmap/_artists/_collections.py +345 -0
  88. figrecipe/_editor/_hitmap/_artists/_images.py +68 -0
  89. figrecipe/_editor/_hitmap/_artists/_lines.py +107 -0
  90. figrecipe/_editor/_hitmap/_artists/_patches.py +163 -0
  91. figrecipe/_editor/_hitmap/_artists/_text.py +190 -0
  92. figrecipe/_editor/_hitmap/_colors.py +181 -0
  93. figrecipe/_editor/_hitmap/_detect.py +137 -0
  94. figrecipe/_editor/_hitmap/_restore.py +154 -0
  95. figrecipe/_editor/_hitmap_main.py +182 -0
  96. figrecipe/_editor/_overrides.py +318 -0
  97. figrecipe/_editor/_preferences.py +135 -0
  98. figrecipe/_editor/_render_overrides.py +480 -0
  99. figrecipe/_editor/_renderer.py +199 -0
  100. figrecipe/_editor/_routes_axis.py +453 -0
  101. figrecipe/_editor/_routes_core.py +284 -0
  102. figrecipe/_editor/_routes_element.py +317 -0
  103. figrecipe/_editor/_routes_style.py +223 -0
  104. figrecipe/_editor/_templates/__init__.py +152 -0
  105. figrecipe/_editor/_templates/_html.py +502 -0
  106. figrecipe/_editor/_templates/_scripts/__init__.py +120 -0
  107. figrecipe/_editor/_templates/_scripts/_api.py +228 -0
  108. figrecipe/_editor/_templates/_scripts/_colors.py +485 -0
  109. figrecipe/_editor/_templates/_scripts/_core.py +436 -0
  110. figrecipe/_editor/_templates/_scripts/_debug_snapshot.py +186 -0
  111. figrecipe/_editor/_templates/_scripts/_element_editor.py +310 -0
  112. figrecipe/_editor/_templates/_scripts/_files.py +195 -0
  113. figrecipe/_editor/_templates/_scripts/_hitmap.py +509 -0
  114. figrecipe/_editor/_templates/_scripts/_inspector.py +315 -0
  115. figrecipe/_editor/_templates/_scripts/_labels.py +464 -0
  116. figrecipe/_editor/_templates/_scripts/_legend_drag.py +265 -0
  117. figrecipe/_editor/_templates/_scripts/_modals.py +226 -0
  118. figrecipe/_editor/_templates/_scripts/_overlays.py +292 -0
  119. figrecipe/_editor/_templates/_scripts/_panel_drag.py +334 -0
  120. figrecipe/_editor/_templates/_scripts/_panel_position.py +279 -0
  121. figrecipe/_editor/_templates/_scripts/_selection.py +237 -0
  122. figrecipe/_editor/_templates/_scripts/_tabs.py +89 -0
  123. figrecipe/_editor/_templates/_scripts/_view_mode.py +107 -0
  124. figrecipe/_editor/_templates/_scripts/_zoom.py +179 -0
  125. figrecipe/_editor/_templates/_styles/__init__.py +69 -0
  126. figrecipe/_editor/_templates/_styles/_base.py +64 -0
  127. figrecipe/_editor/_templates/_styles/_buttons.py +206 -0
  128. figrecipe/_editor/_templates/_styles/_color_input.py +123 -0
  129. figrecipe/_editor/_templates/_styles/_controls.py +265 -0
  130. figrecipe/_editor/_templates/_styles/_dynamic_props.py +144 -0
  131. figrecipe/_editor/_templates/_styles/_forms.py +126 -0
  132. figrecipe/_editor/_templates/_styles/_hitmap.py +184 -0
  133. figrecipe/_editor/_templates/_styles/_inspector.py +90 -0
  134. figrecipe/_editor/_templates/_styles/_labels.py +118 -0
  135. figrecipe/_editor/_templates/_styles/_modals.py +98 -0
  136. figrecipe/_editor/_templates/_styles/_overlays.py +130 -0
  137. figrecipe/_editor/_templates/_styles/_preview.py +225 -0
  138. figrecipe/_editor/_templates/_styles/_selection.py +73 -0
  139. figrecipe/_params/_DECORATION_METHODS.py +33 -0
  140. figrecipe/_params/_PLOTTING_METHODS.py +58 -0
  141. figrecipe/_params/__init__.py +9 -0
  142. figrecipe/_recorder.py +92 -110
  143. figrecipe/_recorder_utils.py +124 -0
  144. figrecipe/_reproducer/__init__.py +18 -0
  145. figrecipe/_reproducer/_core.py +498 -0
  146. figrecipe/_reproducer/_custom_plots.py +279 -0
  147. figrecipe/_reproducer/_seaborn.py +100 -0
  148. figrecipe/_reproducer/_violin.py +186 -0
  149. figrecipe/_seaborn.py +14 -9
  150. figrecipe/_serializer.py +2 -2
  151. figrecipe/_signatures/README.md +68 -0
  152. figrecipe/_signatures/__init__.py +12 -2
  153. figrecipe/_signatures/_kwargs.py +273 -0
  154. figrecipe/_signatures/_loader.py +114 -57
  155. figrecipe/_signatures/_parsing.py +147 -0
  156. figrecipe/_utils/__init__.py +6 -4
  157. figrecipe/_utils/_crop.py +10 -4
  158. figrecipe/_utils/_image_diff.py +37 -33
  159. figrecipe/_utils/_numpy_io.py +0 -1
  160. figrecipe/_utils/_units.py +11 -3
  161. figrecipe/_validator.py +12 -3
  162. figrecipe/_wrappers/_axes.py +193 -170
  163. figrecipe/_wrappers/_axes_helpers.py +136 -0
  164. figrecipe/_wrappers/_axes_plots.py +418 -0
  165. figrecipe/_wrappers/_axes_seaborn.py +157 -0
  166. figrecipe/_wrappers/_figure.py +277 -18
  167. figrecipe/_wrappers/_panel_labels.py +127 -0
  168. figrecipe/_wrappers/_plot_helpers.py +143 -0
  169. figrecipe/_wrappers/_violin_helpers.py +180 -0
  170. figrecipe/plt.py +0 -1
  171. figrecipe/pyplot.py +2 -1
  172. figrecipe/styles/__init__.py +12 -11
  173. figrecipe/styles/_dotdict.py +72 -0
  174. figrecipe/styles/_finalize.py +134 -0
  175. figrecipe/styles/_fonts.py +77 -0
  176. figrecipe/styles/_kwargs_converter.py +178 -0
  177. figrecipe/styles/_plot_styles.py +209 -0
  178. figrecipe/styles/_style_applier.py +60 -202
  179. figrecipe/styles/_style_loader.py +73 -121
  180. figrecipe/styles/_themes.py +151 -0
  181. figrecipe/styles/presets/MATPLOTLIB.yaml +95 -0
  182. figrecipe/styles/presets/SCITEX.yaml +181 -0
  183. figrecipe-0.7.4.dist-info/METADATA +429 -0
  184. figrecipe-0.7.4.dist-info/RECORD +188 -0
  185. figrecipe/_reproducer.py +0 -358
  186. figrecipe-0.5.0.dist-info/METADATA +0 -336
  187. figrecipe-0.5.0.dist-info/RECORD +0 -26
  188. {figrecipe-0.5.0.dist-info → figrecipe-0.7.4.dist-info}/WHEEL +0 -0
  189. {figrecipe-0.5.0.dist-info → figrecipe-0.7.4.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,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"]