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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. figrecipe/__init__.py +106 -973
  2. figrecipe/_api/__init__.py +48 -0
  3. figrecipe/_api/_extract.py +108 -0
  4. figrecipe/_api/_notebook.py +61 -0
  5. figrecipe/_api/_panel.py +46 -0
  6. figrecipe/_api/_save.py +191 -0
  7. figrecipe/_api/_seaborn_proxy.py +34 -0
  8. figrecipe/_api/_style_manager.py +153 -0
  9. figrecipe/_api/_subplots.py +333 -0
  10. figrecipe/_api/_validate.py +82 -0
  11. figrecipe/_dev/__init__.py +2 -93
  12. figrecipe/_dev/_plotters.py +76 -0
  13. figrecipe/_dev/_run_demos.py +56 -0
  14. figrecipe/_dev/demo_plotters/__init__.py +35 -166
  15. figrecipe/_dev/demo_plotters/_categories.py +81 -0
  16. figrecipe/_dev/demo_plotters/_figure_creators.py +119 -0
  17. figrecipe/_dev/demo_plotters/_helpers.py +31 -0
  18. figrecipe/_dev/demo_plotters/_registry.py +50 -0
  19. figrecipe/_dev/demo_plotters/bar_categorical/__init__.py +4 -0
  20. figrecipe/_dev/demo_plotters/contour_surface/__init__.py +4 -0
  21. figrecipe/_dev/demo_plotters/distribution/__init__.py +4 -0
  22. figrecipe/_dev/demo_plotters/image_matrix/__init__.py +4 -0
  23. figrecipe/_dev/demo_plotters/line_curve/__init__.py +4 -0
  24. figrecipe/_dev/demo_plotters/{plot_plot.py → line_curve/plot_plot.py} +3 -2
  25. figrecipe/_dev/demo_plotters/scatter_points/__init__.py +4 -0
  26. figrecipe/_dev/demo_plotters/special/__init__.py +4 -0
  27. figrecipe/_dev/demo_plotters/{plot_pie.py → special/plot_pie.py} +5 -1
  28. figrecipe/_dev/demo_plotters/spectral_signal/__init__.py +4 -0
  29. figrecipe/_dev/demo_plotters/vector_flow/__init__.py +4 -0
  30. figrecipe/_editor/__init__.py +57 -9
  31. figrecipe/_editor/_bbox/__init__.py +43 -0
  32. figrecipe/_editor/_bbox/_collections.py +177 -0
  33. figrecipe/_editor/_bbox/_elements.py +159 -0
  34. figrecipe/_editor/_bbox/_extract.py +256 -0
  35. figrecipe/_editor/_bbox/_extract_axes.py +370 -0
  36. figrecipe/_editor/_bbox/_extract_text.py +342 -0
  37. figrecipe/_editor/_bbox/_lines.py +173 -0
  38. figrecipe/_editor/_bbox/_transforms.py +146 -0
  39. figrecipe/_editor/_flask_app.py +68 -1039
  40. figrecipe/_editor/_helpers.py +242 -0
  41. figrecipe/_editor/_hitmap/__init__.py +76 -0
  42. figrecipe/_editor/_hitmap/_artists/__init__.py +21 -0
  43. figrecipe/_editor/_hitmap/_artists/_collections.py +345 -0
  44. figrecipe/_editor/_hitmap/_artists/_images.py +68 -0
  45. figrecipe/_editor/_hitmap/_artists/_lines.py +107 -0
  46. figrecipe/_editor/_hitmap/_artists/_patches.py +163 -0
  47. figrecipe/_editor/_hitmap/_artists/_text.py +190 -0
  48. figrecipe/_editor/_hitmap/_colors.py +181 -0
  49. figrecipe/_editor/_hitmap/_detect.py +137 -0
  50. figrecipe/_editor/_hitmap/_restore.py +154 -0
  51. figrecipe/_editor/_hitmap_main.py +182 -0
  52. figrecipe/_editor/_preferences.py +135 -0
  53. figrecipe/_editor/_render_overrides.py +480 -0
  54. figrecipe/_editor/_renderer.py +35 -185
  55. figrecipe/_editor/_routes_axis.py +453 -0
  56. figrecipe/_editor/_routes_core.py +284 -0
  57. figrecipe/_editor/_routes_element.py +317 -0
  58. figrecipe/_editor/_routes_style.py +223 -0
  59. figrecipe/_editor/_templates/__init__.py +78 -1
  60. figrecipe/_editor/_templates/_html.py +109 -13
  61. figrecipe/_editor/_templates/_scripts/__init__.py +120 -0
  62. figrecipe/_editor/_templates/_scripts/_api.py +228 -0
  63. figrecipe/_editor/_templates/_scripts/_colors.py +485 -0
  64. figrecipe/_editor/_templates/_scripts/_core.py +436 -0
  65. figrecipe/_editor/_templates/_scripts/_debug_snapshot.py +186 -0
  66. figrecipe/_editor/_templates/_scripts/_element_editor.py +310 -0
  67. figrecipe/_editor/_templates/_scripts/_files.py +195 -0
  68. figrecipe/_editor/_templates/_scripts/_hitmap.py +509 -0
  69. figrecipe/_editor/_templates/_scripts/_inspector.py +315 -0
  70. figrecipe/_editor/_templates/_scripts/_labels.py +464 -0
  71. figrecipe/_editor/_templates/_scripts/_legend_drag.py +265 -0
  72. figrecipe/_editor/_templates/_scripts/_modals.py +226 -0
  73. figrecipe/_editor/_templates/_scripts/_overlays.py +292 -0
  74. figrecipe/_editor/_templates/_scripts/_panel_drag.py +334 -0
  75. figrecipe/_editor/_templates/_scripts/_panel_position.py +279 -0
  76. figrecipe/_editor/_templates/_scripts/_selection.py +237 -0
  77. figrecipe/_editor/_templates/_scripts/_tabs.py +89 -0
  78. figrecipe/_editor/_templates/_scripts/_view_mode.py +107 -0
  79. figrecipe/_editor/_templates/_scripts/_zoom.py +179 -0
  80. figrecipe/_editor/_templates/_styles/__init__.py +69 -0
  81. figrecipe/_editor/_templates/_styles/_base.py +64 -0
  82. figrecipe/_editor/_templates/_styles/_buttons.py +206 -0
  83. figrecipe/_editor/_templates/_styles/_color_input.py +123 -0
  84. figrecipe/_editor/_templates/_styles/_controls.py +265 -0
  85. figrecipe/_editor/_templates/_styles/_dynamic_props.py +144 -0
  86. figrecipe/_editor/_templates/_styles/_forms.py +126 -0
  87. figrecipe/_editor/_templates/_styles/_hitmap.py +184 -0
  88. figrecipe/_editor/_templates/_styles/_inspector.py +90 -0
  89. figrecipe/_editor/_templates/_styles/_labels.py +118 -0
  90. figrecipe/_editor/_templates/_styles/_modals.py +98 -0
  91. figrecipe/_editor/_templates/_styles/_overlays.py +130 -0
  92. figrecipe/_editor/_templates/_styles/_preview.py +225 -0
  93. figrecipe/_editor/_templates/_styles/_selection.py +73 -0
  94. figrecipe/_params/_DECORATION_METHODS.py +6 -0
  95. figrecipe/_recorder.py +35 -106
  96. figrecipe/_recorder_utils.py +124 -0
  97. figrecipe/_reproducer/__init__.py +18 -0
  98. figrecipe/_reproducer/_core.py +498 -0
  99. figrecipe/_reproducer/_custom_plots.py +279 -0
  100. figrecipe/_reproducer/_seaborn.py +100 -0
  101. figrecipe/_reproducer/_violin.py +186 -0
  102. figrecipe/_signatures/_kwargs.py +273 -0
  103. figrecipe/_signatures/_loader.py +21 -423
  104. figrecipe/_signatures/_parsing.py +147 -0
  105. figrecipe/_wrappers/_axes.py +119 -910
  106. figrecipe/_wrappers/_axes_helpers.py +136 -0
  107. figrecipe/_wrappers/_axes_plots.py +418 -0
  108. figrecipe/_wrappers/_axes_seaborn.py +157 -0
  109. figrecipe/_wrappers/_figure.py +162 -0
  110. figrecipe/_wrappers/_panel_labels.py +127 -0
  111. figrecipe/_wrappers/_plot_helpers.py +143 -0
  112. figrecipe/_wrappers/_violin_helpers.py +180 -0
  113. figrecipe/styles/__init__.py +8 -6
  114. figrecipe/styles/_dotdict.py +72 -0
  115. figrecipe/styles/_finalize.py +134 -0
  116. figrecipe/styles/_fonts.py +77 -0
  117. figrecipe/styles/_kwargs_converter.py +178 -0
  118. figrecipe/styles/_plot_styles.py +209 -0
  119. figrecipe/styles/_style_applier.py +32 -478
  120. figrecipe/styles/_style_loader.py +16 -192
  121. figrecipe/styles/_themes.py +151 -0
  122. figrecipe/styles/presets/MATPLOTLIB.yaml +2 -1
  123. figrecipe/styles/presets/SCITEX.yaml +29 -24
  124. {figrecipe-0.6.0.dist-info → figrecipe-0.7.4.dist-info}/METADATA +37 -2
  125. figrecipe-0.7.4.dist-info/RECORD +188 -0
  126. figrecipe/_editor/_bbox.py +0 -978
  127. figrecipe/_editor/_hitmap.py +0 -937
  128. figrecipe/_editor/_templates/_scripts.py +0 -2778
  129. figrecipe/_editor/_templates/_styles.py +0 -1326
  130. figrecipe/_reproducer.py +0 -975
  131. figrecipe-0.6.0.dist-info/RECORD +0 -90
  132. /figrecipe/_dev/demo_plotters/{plot_bar.py → bar_categorical/plot_bar.py} +0 -0
  133. /figrecipe/_dev/demo_plotters/{plot_barh.py → bar_categorical/plot_barh.py} +0 -0
  134. /figrecipe/_dev/demo_plotters/{plot_contour.py → contour_surface/plot_contour.py} +0 -0
  135. /figrecipe/_dev/demo_plotters/{plot_contourf.py → contour_surface/plot_contourf.py} +0 -0
  136. /figrecipe/_dev/demo_plotters/{plot_tricontour.py → contour_surface/plot_tricontour.py} +0 -0
  137. /figrecipe/_dev/demo_plotters/{plot_tricontourf.py → contour_surface/plot_tricontourf.py} +0 -0
  138. /figrecipe/_dev/demo_plotters/{plot_tripcolor.py → contour_surface/plot_tripcolor.py} +0 -0
  139. /figrecipe/_dev/demo_plotters/{plot_triplot.py → contour_surface/plot_triplot.py} +0 -0
  140. /figrecipe/_dev/demo_plotters/{plot_boxplot.py → distribution/plot_boxplot.py} +0 -0
  141. /figrecipe/_dev/demo_plotters/{plot_ecdf.py → distribution/plot_ecdf.py} +0 -0
  142. /figrecipe/_dev/demo_plotters/{plot_hist.py → distribution/plot_hist.py} +0 -0
  143. /figrecipe/_dev/demo_plotters/{plot_hist2d.py → distribution/plot_hist2d.py} +0 -0
  144. /figrecipe/_dev/demo_plotters/{plot_violinplot.py → distribution/plot_violinplot.py} +0 -0
  145. /figrecipe/_dev/demo_plotters/{plot_hexbin.py → image_matrix/plot_hexbin.py} +0 -0
  146. /figrecipe/_dev/demo_plotters/{plot_imshow.py → image_matrix/plot_imshow.py} +0 -0
  147. /figrecipe/_dev/demo_plotters/{plot_matshow.py → image_matrix/plot_matshow.py} +0 -0
  148. /figrecipe/_dev/demo_plotters/{plot_pcolor.py → image_matrix/plot_pcolor.py} +0 -0
  149. /figrecipe/_dev/demo_plotters/{plot_pcolormesh.py → image_matrix/plot_pcolormesh.py} +0 -0
  150. /figrecipe/_dev/demo_plotters/{plot_spy.py → image_matrix/plot_spy.py} +0 -0
  151. /figrecipe/_dev/demo_plotters/{plot_errorbar.py → line_curve/plot_errorbar.py} +0 -0
  152. /figrecipe/_dev/demo_plotters/{plot_fill.py → line_curve/plot_fill.py} +0 -0
  153. /figrecipe/_dev/demo_plotters/{plot_fill_between.py → line_curve/plot_fill_between.py} +0 -0
  154. /figrecipe/_dev/demo_plotters/{plot_fill_betweenx.py → line_curve/plot_fill_betweenx.py} +0 -0
  155. /figrecipe/_dev/demo_plotters/{plot_stackplot.py → line_curve/plot_stackplot.py} +0 -0
  156. /figrecipe/_dev/demo_plotters/{plot_stairs.py → line_curve/plot_stairs.py} +0 -0
  157. /figrecipe/_dev/demo_plotters/{plot_step.py → line_curve/plot_step.py} +0 -0
  158. /figrecipe/_dev/demo_plotters/{plot_scatter.py → scatter_points/plot_scatter.py} +0 -0
  159. /figrecipe/_dev/demo_plotters/{plot_eventplot.py → special/plot_eventplot.py} +0 -0
  160. /figrecipe/_dev/demo_plotters/{plot_loglog.py → special/plot_loglog.py} +0 -0
  161. /figrecipe/_dev/demo_plotters/{plot_semilogx.py → special/plot_semilogx.py} +0 -0
  162. /figrecipe/_dev/demo_plotters/{plot_semilogy.py → special/plot_semilogy.py} +0 -0
  163. /figrecipe/_dev/demo_plotters/{plot_stem.py → special/plot_stem.py} +0 -0
  164. /figrecipe/_dev/demo_plotters/{plot_acorr.py → spectral_signal/plot_acorr.py} +0 -0
  165. /figrecipe/_dev/demo_plotters/{plot_angle_spectrum.py → spectral_signal/plot_angle_spectrum.py} +0 -0
  166. /figrecipe/_dev/demo_plotters/{plot_cohere.py → spectral_signal/plot_cohere.py} +0 -0
  167. /figrecipe/_dev/demo_plotters/{plot_csd.py → spectral_signal/plot_csd.py} +0 -0
  168. /figrecipe/_dev/demo_plotters/{plot_magnitude_spectrum.py → spectral_signal/plot_magnitude_spectrum.py} +0 -0
  169. /figrecipe/_dev/demo_plotters/{plot_phase_spectrum.py → spectral_signal/plot_phase_spectrum.py} +0 -0
  170. /figrecipe/_dev/demo_plotters/{plot_psd.py → spectral_signal/plot_psd.py} +0 -0
  171. /figrecipe/_dev/demo_plotters/{plot_specgram.py → spectral_signal/plot_specgram.py} +0 -0
  172. /figrecipe/_dev/demo_plotters/{plot_xcorr.py → spectral_signal/plot_xcorr.py} +0 -0
  173. /figrecipe/_dev/demo_plotters/{plot_barbs.py → vector_flow/plot_barbs.py} +0 -0
  174. /figrecipe/_dev/demo_plotters/{plot_quiver.py → vector_flow/plot_quiver.py} +0 -0
  175. /figrecipe/_dev/demo_plotters/{plot_streamplot.py → vector_flow/plot_streamplot.py} +0 -0
  176. {figrecipe-0.6.0.dist-info → figrecipe-0.7.4.dist-info}/WHEEL +0 -0
  177. {figrecipe-0.6.0.dist-info → figrecipe-0.7.4.dist-info}/licenses/LICENSE +0 -0
@@ -28,11 +28,7 @@ from pathlib import Path
28
28
  from typing import Any, Dict, Optional
29
29
 
30
30
  from .._wrappers import RecordingFigure
31
- from ._overrides import (
32
- create_overrides_from_style,
33
- load_overrides,
34
- save_overrides,
35
- )
31
+ from ._overrides import create_overrides_from_style, load_overrides
36
32
 
37
33
 
38
34
  class FigureEditor:
@@ -46,6 +42,7 @@ class FigureEditor:
46
42
  - Dark/light theme toggle
47
43
  - Download in PNG/SVG/PDF formats
48
44
  - Separate storage of manual overrides (can restore to original)
45
+ - Hot reload: server restarts on source file changes (like Django)
49
46
  """
50
47
 
51
48
  def __init__(
@@ -57,6 +54,8 @@ class FigureEditor:
57
54
  static_png_path: Optional[Path] = None,
58
55
  hitmap_base64: Optional[str] = None,
59
56
  color_map: Optional[Dict] = None,
57
+ hot_reload: bool = False,
58
+ working_dir: Optional[Path] = None,
60
59
  ):
61
60
  """
62
61
  Initialize figure editor.
@@ -77,11 +76,22 @@ class FigureEditor:
77
76
  Pre-generated hitmap as base64.
78
77
  color_map : dict, optional
79
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).
80
83
  """
81
84
  self.fig = fig
82
85
  self.recipe_path = Path(recipe_path) if recipe_path else None
83
86
  self.port = port
84
- self.dark_mode = False
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)
85
95
 
86
96
  # Pre-rendered static PNG (source of truth)
87
97
  self._static_png_path = static_png_path
@@ -92,12 +102,17 @@ class FigureEditor:
92
102
  with open(static_png_path, "rb") as f:
93
103
  self._initial_base64 = base64.b64encode(f.read()).decode("utf-8")
94
104
 
105
+ # Store original axes positions for restore functionality
106
+ self._initial_axes_positions = self._capture_axes_positions()
107
+
95
108
  # Initialize style overrides system
96
109
  self._init_style_overrides(style)
97
110
 
98
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
99
114
  self._hitmap_base64 = hitmap_base64
100
- self._color_map = color_map
115
+ self._color_map = color_map if color_map is not None else {}
101
116
 
102
117
  def _init_style_overrides(self, programmatic_style: Optional[Dict[str, Any]]):
103
118
  """Initialize the layered style override system."""
@@ -106,14 +121,13 @@ class FigureEditor:
106
121
  existing = load_overrides(self.recipe_path)
107
122
  if existing:
108
123
  self.style_overrides = existing
109
- # Update programmatic style if provided
110
124
  if programmatic_style:
111
125
  self.style_overrides.programmatic_style = programmatic_style
112
126
  return
113
127
 
114
- # Get base style from global preset (always ensure we have a base style)
128
+ # Get base style from global preset
115
129
  base_style = {}
116
- style_name = "SCITEX" # Default
130
+ style_name = "SCITEX"
117
131
  try:
118
132
  from ..styles._style_loader import (
119
133
  _CURRENT_STYLE_NAME,
@@ -122,11 +136,9 @@ class FigureEditor:
122
136
  to_subplots_kwargs,
123
137
  )
124
138
 
125
- # If no style is loaded, load the default SCITEX style
126
139
  if _STYLE_CACHE is None:
127
140
  load_style("SCITEX")
128
141
 
129
- # Get the style cache (now guaranteed to exist)
130
142
  from ..styles._style_loader import _STYLE_CACHE
131
143
 
132
144
  if _STYLE_CACHE is not None:
@@ -135,10 +147,8 @@ class FigureEditor:
135
147
  except Exception:
136
148
  pass
137
149
 
138
- # Store the style name for UI display
139
150
  self._style_name = style_name
140
151
 
141
- # Create new overrides
142
152
  self.style_overrides = create_overrides_from_style(
143
153
  base_style=base_style,
144
154
  programmatic_style=programmatic_style or {},
@@ -163,6 +173,27 @@ class FigureEditor:
163
173
  """Get the final merged style."""
164
174
  return self.style_overrides.get_effective_style()
165
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
+
166
197
  def run(self, open_browser: bool = True) -> Dict[str, Any]:
167
198
  """
168
199
  Run the editor server.
@@ -177,1053 +208,51 @@ class FigureEditor:
177
208
  dict
178
209
  Final style overrides after editing session.
179
210
  """
180
- from flask import Flask, jsonify, render_template_string, request, send_file
211
+ from flask import Flask
181
212
 
182
- from ._bbox import extract_bboxes
183
- from ._hitmap import generate_hitmap, hitmap_to_base64
184
- from ._renderer import render_download
185
- from ._templates import build_html_template
186
-
187
- # Use specified port strictly (no fallback)
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
188
217
 
189
218
  # Defer hitmap generation until first request (lazy loading)
190
- # This makes the editor start immediately
191
219
  self._hitmap_generated = self._hitmap_base64 is not None
192
220
 
193
221
  # Create Flask app
194
222
  app = Flask(__name__)
195
- editor = self
196
-
197
- @app.route("/")
198
- def index():
199
- """Main editor page."""
200
- # Always render with effective style (base + programmatic + manual)
201
- # to ensure YAML style settings are applied
202
- base64_img, bboxes, img_size = _render_with_overrides(
203
- editor.fig,
204
- editor.get_effective_style(),
205
- editor.dark_mode,
206
- )
207
-
208
- # Get style name (default to SCITEX if not set)
209
- style_name = getattr(editor, "_style_name", "SCITEX")
210
-
211
- # Build HTML template
212
- html = build_html_template(
213
- image_base64=base64_img,
214
- bboxes=bboxes,
215
- color_map=editor._color_map,
216
- style=editor.style,
217
- overrides=editor.get_effective_style(),
218
- img_size=img_size,
219
- style_name=style_name,
220
- )
221
-
222
- return render_template_string(html)
223
-
224
- @app.route("/preview")
225
- def preview():
226
- """Get current preview image."""
227
- # Always render with effective style (base + programmatic + manual)
228
- base64_img, bboxes, img_size = _render_with_overrides(
229
- editor.fig,
230
- editor.get_effective_style(),
231
- editor.dark_mode,
232
- )
233
-
234
- return jsonify(
235
- {
236
- "image": base64_img,
237
- "bboxes": bboxes,
238
- "img_size": {"width": img_size[0], "height": img_size[1]},
239
- }
240
- )
241
-
242
- @app.route("/update", methods=["POST"])
243
- def update():
244
- """Update preview with new style overrides."""
245
- data = request.get_json() or {}
246
-
247
- # Update manual overrides
248
- editor.overrides.update(data.get("overrides", {}))
249
- editor.dark_mode = data.get("dark_mode", editor.dark_mode)
250
-
251
- # Re-render with effective style (base + programmatic + manual)
252
- base64_img, bboxes, img_size = _render_with_overrides(
253
- editor.fig,
254
- editor.get_effective_style(),
255
- editor.dark_mode,
256
- )
257
-
258
- return jsonify(
259
- {
260
- "image": base64_img,
261
- "bboxes": bboxes,
262
- "img_size": {"width": img_size[0], "height": img_size[1]},
263
- }
264
- )
265
-
266
- @app.route("/hitmap")
267
- def hitmap():
268
- """Get hitmap image and color map (lazy generation on first request)."""
269
- # Generate hitmap on first request if not already done
270
- if not editor._hitmap_generated:
271
- print("Generating hitmap (first request)...")
272
- hitmap_img, editor._color_map = generate_hitmap(editor.fig)
273
- editor._hitmap_base64 = hitmap_to_base64(hitmap_img)
274
- editor._hitmap_generated = True
275
- print("Hitmap ready.")
276
-
277
- return jsonify(
278
- {
279
- "image": editor._hitmap_base64,
280
- "color_map": editor._color_map,
281
- }
282
- )
283
-
284
- def _to_json_serializable(obj):
285
- """Convert numpy arrays and other non-serializable objects to JSON-safe types."""
286
- import numpy as np
287
-
288
- if isinstance(obj, np.ndarray):
289
- return obj.tolist()
290
- elif isinstance(obj, (np.integer, np.floating)):
291
- return obj.item()
292
- elif isinstance(obj, dict):
293
- return {k: _to_json_serializable(v) for k, v in obj.items()}
294
- elif isinstance(obj, (list, tuple)):
295
- return [_to_json_serializable(item) for item in obj]
296
- return obj
297
-
298
- @app.route("/calls")
299
- def get_calls():
300
- """Get all recorded calls with their signatures."""
301
- from .._signatures import get_signature
302
-
303
- calls_data = {}
304
- if hasattr(editor.fig, "record"):
305
- for ax_key, ax_record in editor.fig.record.axes.items():
306
- for call in ax_record.calls:
307
- call_id = call.id
308
- func_name = call.function
309
- sig = get_signature(func_name)
310
-
311
- calls_data[call_id] = {
312
- "function": func_name,
313
- "ax_key": ax_key,
314
- "args": _to_json_serializable(call.args),
315
- "kwargs": _to_json_serializable(call.kwargs),
316
- "signature": {
317
- "args": sig.get("args", []),
318
- "kwargs": {
319
- k: v
320
- for k, v in sig.get("kwargs", {}).items()
321
- if k != "**kwargs"
322
- },
323
- },
324
- }
325
-
326
- return jsonify(calls_data)
327
-
328
- @app.route("/call/<call_id>")
329
- def get_call(call_id):
330
- """Get recorded call data by call_id."""
331
- from .._signatures import get_signature
332
-
333
- if hasattr(editor.fig, "record"):
334
- for ax_key, ax_record in editor.fig.record.axes.items():
335
- for call in ax_record.calls:
336
- if call.id == call_id:
337
- sig = get_signature(call.function)
338
- return jsonify(
339
- {
340
- "call_id": call_id,
341
- "function": call.function,
342
- "ax_key": ax_key,
343
- "args": call.args,
344
- "kwargs": call.kwargs,
345
- "signature": {
346
- "args": sig.get("args", []),
347
- "kwargs": {
348
- k: v
349
- for k, v in sig.get("kwargs", {}).items()
350
- if k != "**kwargs"
351
- },
352
- },
353
- }
354
- )
355
-
356
- return jsonify({"error": f"Call {call_id} not found"}), 404
357
-
358
- @app.route("/style")
359
- def get_style():
360
- """Get current style configuration."""
361
- return jsonify(
362
- {
363
- "base_style": editor.style_overrides.base_style,
364
- "programmatic_style": editor.style_overrides.programmatic_style,
365
- "manual_overrides": editor.style_overrides.manual_overrides,
366
- "effective_style": editor.get_effective_style(),
367
- "has_overrides": editor.style_overrides.has_manual_overrides(),
368
- "manual_timestamp": editor.style_overrides.manual_timestamp,
369
- }
370
- )
371
-
372
- @app.route("/theme")
373
- def get_theme():
374
- """Get current theme YAML content for display."""
375
- import io as yaml_io
376
-
377
- from ruamel.yaml import YAML
378
-
379
- style = editor.get_effective_style()
380
- style_name = style.get("_name", "SCITEX")
381
-
382
- # Serialize to YAML
383
- yaml = YAML()
384
- yaml.default_flow_style = False
385
- yaml.indent(mapping=2, sequence=4, offset=2)
386
- stream = yaml_io.StringIO()
387
- yaml.dump(style, stream)
388
- yaml_content = stream.getvalue()
389
-
390
- return jsonify(
391
- {
392
- "name": style_name,
393
- "content": yaml_content,
394
- }
395
- )
396
-
397
- @app.route("/list_themes")
398
- def list_themes():
399
- """List available theme presets."""
400
- from ..styles._style_loader import list_presets
401
-
402
- presets = list_presets()
403
- current = editor.get_effective_style().get("_name", "SCITEX")
404
-
405
- return jsonify(
406
- {
407
- "themes": presets,
408
- "current": current,
409
- }
410
- )
411
-
412
- @app.route("/switch_theme", methods=["POST"])
413
- def switch_theme():
414
- """Switch to a different theme preset by reproducing the figure."""
415
- from .._reproducer import reproduce_from_record
416
- from ..styles._style_loader import load_preset
417
-
418
- data = request.get_json() or {}
419
- theme_name = data.get("theme")
420
-
421
- if not theme_name:
422
- return jsonify({"error": "No theme specified"}), 400
423
-
424
- try:
425
- # Load the new preset
426
- new_style = load_preset(theme_name)
427
-
428
- if new_style is None:
429
- return jsonify({"error": f"Theme '{theme_name}' not found"}), 404
430
-
431
- # Update the base style
432
- editor.style_overrides.base_style = dict(new_style)
433
- editor.style_overrides.base_style["_name"] = theme_name
434
-
435
- # Reproduce the figure from the record
436
- if hasattr(editor.fig, "record") and editor.fig.record is not None:
437
- # Update the record's style to use new theme
438
- old_style = editor.fig.record.style
439
- editor.fig.record.style = dict(new_style)
440
-
441
- # Reproduce figure with new style
442
- new_fig, new_ax = reproduce_from_record(editor.fig.record)
443
- editor.fig = new_fig
444
-
445
- # Restore original style in record for future reference
446
- editor.fig.record.style = old_style
447
-
448
- # Apply behavior settings from new theme directly to figure
449
- mpl_fig = editor.fig.fig if hasattr(editor.fig, "fig") else editor.fig
450
- behavior = new_style.get("behavior", {})
451
- for ax in mpl_fig.get_axes():
452
- # Apply spine visibility
453
- hide_top = behavior.get("hide_top_spine", True)
454
- hide_right = behavior.get("hide_right_spine", True)
455
- ax.spines["top"].set_visible(not hide_top)
456
- ax.spines["right"].set_visible(not hide_right)
457
-
458
- # Apply grid setting
459
- if behavior.get("grid", False):
460
- ax.grid(True, alpha=0.3)
461
- else:
462
- ax.grid(False)
463
-
464
- # Re-render with new theme
465
- base64_img, bboxes, img_size = _render_with_overrides(
466
- editor.fig,
467
- editor.get_effective_style(),
468
- editor.dark_mode,
469
- )
470
-
471
- # Get updated form values from new style
472
- form_values = _get_form_values_from_style(editor.get_effective_style())
473
-
474
- return jsonify(
475
- {
476
- "success": True,
477
- "theme": theme_name,
478
- "image": base64_img,
479
- "bboxes": bboxes,
480
- "img_size": {"width": img_size[0], "height": img_size[1]},
481
- "values": form_values,
482
- }
483
- )
484
-
485
- except Exception as e:
486
- import traceback
487
-
488
- traceback.print_exc()
489
- return jsonify({"error": f"Failed to switch theme: {str(e)}"}), 500
490
-
491
- @app.route("/save", methods=["POST"])
492
- def save():
493
- """Save style overrides (stored separately from recipe)."""
494
- data = request.get_json() or {}
495
- editor.style_overrides.update_manual_overrides(data.get("overrides", {}))
496
-
497
- # Save to .overrides.json file
498
- if editor.recipe_path:
499
- path = save_overrides(editor.style_overrides, editor.recipe_path)
500
- return jsonify(
501
- {
502
- "success": True,
503
- "path": str(path),
504
- "has_overrides": editor.style_overrides.has_manual_overrides(),
505
- "timestamp": editor.style_overrides.manual_timestamp,
506
- }
507
- )
508
-
509
- return jsonify(
510
- {
511
- "success": True,
512
- "overrides": editor.overrides,
513
- "has_overrides": editor.style_overrides.has_manual_overrides(),
514
- }
515
- )
516
-
517
- @app.route("/restore", methods=["POST"])
518
- def restore():
519
- """Restore to original style (clear manual overrides)."""
520
- editor.style_overrides.clear_manual_overrides()
521
-
522
- # Use pre-rendered static PNG (source of truth)
523
- if editor._initial_base64 and not editor.dark_mode:
524
- base64_img = editor._initial_base64
525
- import base64 as b64
526
- import io
527
-
528
- from PIL import Image
529
-
530
- img_data = b64.b64decode(base64_img)
531
- img = Image.open(io.BytesIO(img_data))
532
- img_size = img.size
533
- mpl_fig = editor.fig.fig if hasattr(editor.fig, "fig") else editor.fig
534
- original_dpi = mpl_fig.dpi
535
- mpl_fig.set_dpi(150)
536
- mpl_fig.canvas.draw()
537
- bboxes = extract_bboxes(mpl_fig, img_size[0], img_size[1])
538
- mpl_fig.set_dpi(original_dpi)
539
- else:
540
- # Fallback: re-render with reproduce pipeline
541
- base64_img, bboxes, img_size = _render_with_overrides(
542
- editor.fig,
543
- None,
544
- editor.dark_mode,
545
- )
546
-
547
- return jsonify(
548
- {
549
- "success": True,
550
- "image": base64_img,
551
- "bboxes": bboxes,
552
- "img_size": {"width": img_size[0], "height": img_size[1]},
553
- "original_style": editor.style,
554
- }
555
- )
556
-
557
- @app.route("/diff")
558
- def get_diff():
559
- """Get differences between original and manual overrides."""
560
- return jsonify(
561
- {
562
- "diff": editor.style_overrides.get_diff(),
563
- "has_overrides": editor.style_overrides.has_manual_overrides(),
564
- }
565
- )
566
-
567
- @app.route("/update_label", methods=["POST"])
568
- def update_label():
569
- """Update axis labels (title, xlabel, ylabel, suptitle).
570
-
571
- These are editable text elements that don't affect data integrity.
572
- """
573
- data = request.get_json() or {}
574
- label_type = data.get("label_type") # title, xlabel, ylabel, suptitle
575
- text = data.get("text", "")
576
- ax_index = data.get("ax_index", 0) # For multi-axes figures
577
-
578
- if not label_type:
579
- return jsonify({"error": "Missing label_type"}), 400
580
-
581
- # Get the underlying matplotlib figure
582
- mpl_fig = editor.fig.fig if hasattr(editor.fig, "fig") else editor.fig
583
- axes = mpl_fig.get_axes()
584
-
585
- if not axes:
586
- return jsonify({"error": "No axes found"}), 400
587
-
588
- # Get target axes (default to first)
589
- ax = axes[min(ax_index, len(axes) - 1)]
590
-
591
- try:
592
- if label_type == "title":
593
- ax.set_title(text)
594
- elif label_type == "xlabel":
595
- ax.set_xlabel(text)
596
- elif label_type == "ylabel":
597
- ax.set_ylabel(text)
598
- elif label_type == "suptitle":
599
- if text:
600
- mpl_fig.suptitle(text)
601
- else:
602
- # Clear suptitle by setting to empty string
603
- if mpl_fig._suptitle:
604
- mpl_fig._suptitle.set_text("")
605
- else:
606
- return jsonify({"error": f"Unknown label_type: {label_type}"}), 400
607
-
608
- # Track override
609
- editor.style_overrides.manual_overrides[f"label_{label_type}"] = text
610
-
611
- # Re-render
612
- base64_img, bboxes, img_size = _render_with_overrides(
613
- editor.fig,
614
- editor.get_effective_style(),
615
- editor.dark_mode,
616
- )
617
-
618
- return jsonify(
619
- {
620
- "success": True,
621
- "image": base64_img,
622
- "bboxes": bboxes,
623
- "img_size": {"width": img_size[0], "height": img_size[1]},
624
- }
625
- )
626
-
627
- except Exception as e:
628
- import traceback
629
-
630
- traceback.print_exc()
631
- return jsonify({"error": f"Update failed: {str(e)}"}), 500
632
-
633
- @app.route("/get_labels")
634
- def get_labels():
635
- """Get current axis labels (title, xlabel, ylabel, suptitle)."""
636
- mpl_fig = editor.fig.fig if hasattr(editor.fig, "fig") else editor.fig
637
- axes = mpl_fig.get_axes()
638
-
639
- labels = {
640
- "title": "",
641
- "xlabel": "",
642
- "ylabel": "",
643
- "suptitle": "",
644
- }
645
-
646
- if axes:
647
- ax = axes[0] # Use first axes for now
648
- labels["title"] = ax.get_title()
649
- labels["xlabel"] = ax.get_xlabel()
650
- labels["ylabel"] = ax.get_ylabel()
651
-
652
- if mpl_fig._suptitle:
653
- labels["suptitle"] = mpl_fig._suptitle.get_text()
654
-
655
- return jsonify(labels)
656
-
657
- @app.route("/update_axis_type", methods=["POST"])
658
- def update_axis_type():
659
- """Update axis type (numerical vs categorical).
660
-
661
- Numerical: linear scale with auto ticks
662
- Categorical: discrete labels at integer positions
663
- """
664
- data = request.get_json() or {}
665
- axis = data.get("axis") # "x" or "y"
666
- axis_type = data.get("type") # "numerical" or "categorical"
667
- labels = data.get("labels", []) # For categorical: list of labels
668
- ax_index = data.get("ax_index", 0)
669
-
670
- if not axis or not axis_type:
671
- return jsonify({"error": "Missing axis or type"}), 400
672
-
673
- # Get the underlying matplotlib figure
674
- mpl_fig = editor.fig.fig if hasattr(editor.fig, "fig") else editor.fig
675
- axes_list = mpl_fig.get_axes()
676
-
677
- if not axes_list:
678
- return jsonify({"error": "No axes found"}), 400
679
-
680
- ax = axes_list[min(ax_index, len(axes_list) - 1)]
681
-
682
- try:
683
- if axis == "x":
684
- if axis_type == "categorical" and labels:
685
- # Set categorical x-axis
686
- positions = list(range(len(labels)))
687
- ax.set_xticks(positions)
688
- ax.set_xticklabels(labels)
689
- else:
690
- # Reset to numerical
691
- ax.xaxis.set_major_locator(matplotlib.ticker.AutoLocator())
692
- ax.xaxis.set_major_formatter(
693
- matplotlib.ticker.ScalarFormatter()
694
- )
695
- elif axis == "y":
696
- if axis_type == "categorical" and labels:
697
- # Set categorical y-axis
698
- positions = list(range(len(labels)))
699
- ax.set_yticks(positions)
700
- ax.set_yticklabels(labels)
701
- else:
702
- # Reset to numerical
703
- ax.yaxis.set_major_locator(matplotlib.ticker.AutoLocator())
704
- ax.yaxis.set_major_formatter(
705
- matplotlib.ticker.ScalarFormatter()
706
- )
707
-
708
- # Track override
709
- key = f"axis_{axis}_type"
710
- editor.style_overrides.manual_overrides[key] = axis_type
711
- if labels:
712
- editor.style_overrides.manual_overrides[f"axis_{axis}_labels"] = (
713
- labels
714
- )
715
223
 
716
- # Re-render
717
- base64_img, bboxes, img_size = _render_with_overrides(
718
- editor.fig,
719
- editor.get_effective_style(),
720
- editor.dark_mode,
721
- )
722
-
723
- return jsonify(
724
- {
725
- "success": True,
726
- "image": base64_img,
727
- "bboxes": bboxes,
728
- "img_size": {"width": img_size[0], "height": img_size[1]},
729
- }
730
- )
731
-
732
- except Exception as e:
733
- import traceback
734
-
735
- traceback.print_exc()
736
- return jsonify({"error": f"Update failed: {str(e)}"}), 500
737
-
738
- @app.route("/get_axis_info")
739
- def get_axis_info():
740
- """Get current axis type info (numerical vs categorical)."""
741
- mpl_fig = editor.fig.fig if hasattr(editor.fig, "fig") else editor.fig
742
- axes_list = mpl_fig.get_axes()
743
-
744
- info = {
745
- "x_type": "numerical",
746
- "y_type": "numerical",
747
- "x_labels": [],
748
- "y_labels": [],
749
- }
750
-
751
- if axes_list:
752
- ax = axes_list[0]
753
-
754
- # Check if x-axis has custom tick labels
755
- x_ticklabels = [t.get_text() for t in ax.get_xticklabels()]
756
- if x_ticklabels and any(t for t in x_ticklabels):
757
- info["x_type"] = "categorical"
758
- info["x_labels"] = x_ticklabels
759
-
760
- # Check if y-axis has custom tick labels
761
- y_ticklabels = [t.get_text() for t in ax.get_yticklabels()]
762
- if y_ticklabels and any(t for t in y_ticklabels):
763
- info["y_type"] = "categorical"
764
- info["y_labels"] = y_ticklabels
765
-
766
- return jsonify(info)
767
-
768
- @app.route("/update_legend_position", methods=["POST"])
769
- def update_legend_position():
770
- """Update legend position, visibility, or custom xy coordinates.
771
-
772
- For custom positioning, uses bbox_to_anchor with axes coordinates.
773
- """
774
- data = request.get_json() or {}
775
- loc = data.get("loc") # 'best', 'upper right', 'custom', etc.
776
- x = data.get("x") # For custom: 0-1+ (axes coordinates)
777
- y = data.get("y") # For custom: 0-1+ (axes coordinates)
778
- visible = data.get("visible") # True/False for show/hide
779
- ax_index = data.get("ax_index", 0)
780
-
781
- # Get the underlying matplotlib figure
782
- mpl_fig = editor.fig.fig if hasattr(editor.fig, "fig") else editor.fig
783
- axes_list = mpl_fig.get_axes()
784
-
785
- if not axes_list:
786
- return jsonify({"error": "No axes found"}), 400
787
-
788
- ax = axes_list[min(ax_index, len(axes_list) - 1)]
789
- legend = ax.get_legend()
790
-
791
- if legend is None:
792
- return jsonify({"error": "No legend found on this axes"}), 400
793
-
794
- try:
795
- # Handle visibility toggle
796
- if visible is not None:
797
- legend.set_visible(visible)
798
- editor.style_overrides.manual_overrides["legend_visible"] = visible
799
-
800
- # Handle position update only if loc is provided
801
- if loc is not None:
802
- if loc == "custom" and x is not None and y is not None:
803
- # Custom positioning with bbox_to_anchor
804
- legend.set_bbox_to_anchor((float(x), float(y)))
805
- legend._loc = 2 # upper left as reference point
806
- else:
807
- # Standard location string
808
- loc_map = {
809
- "best": 0,
810
- "upper right": 1,
811
- "upper left": 2,
812
- "lower left": 3,
813
- "lower right": 4,
814
- "right": 5,
815
- "center left": 6,
816
- "center right": 7,
817
- "lower center": 8,
818
- "upper center": 9,
819
- "center": 10,
820
- }
821
- loc_code = loc_map.get(loc, 0)
822
- legend._loc = loc_code
823
- # Clear bbox_to_anchor when using standard loc
824
- legend.set_bbox_to_anchor(None)
825
-
826
- # Track override
827
- editor.style_overrides.manual_overrides["legend_loc"] = loc
828
- if loc == "custom":
829
- editor.style_overrides.manual_overrides["legend_x"] = x
830
- editor.style_overrides.manual_overrides["legend_y"] = y
831
-
832
- # Re-render
833
- base64_img, bboxes, img_size = _render_with_overrides(
834
- editor.fig,
835
- editor.get_effective_style(),
836
- editor.dark_mode,
837
- )
838
-
839
- return jsonify(
840
- {
841
- "success": True,
842
- "image": base64_img,
843
- "bboxes": bboxes,
844
- "img_size": {"width": img_size[0], "height": img_size[1]},
845
- }
846
- )
847
-
848
- except Exception as e:
849
- import traceback
850
-
851
- traceback.print_exc()
852
- return jsonify({"error": f"Update failed: {str(e)}"}), 500
853
-
854
- @app.route("/get_legend_info")
855
- def get_legend_info():
856
- """Get current legend position info."""
857
- mpl_fig = editor.fig.fig if hasattr(editor.fig, "fig") else editor.fig
858
- axes_list = mpl_fig.get_axes()
859
-
860
- info = {
861
- "has_legend": False,
862
- "visible": True,
863
- "loc": "best",
864
- "x": None,
865
- "y": None,
866
- }
867
-
868
- if axes_list:
869
- ax = axes_list[0]
870
- legend = ax.get_legend()
871
-
872
- if legend is not None:
873
- info["has_legend"] = True
874
- info["visible"] = legend.get_visible()
875
-
876
- # Get location code and convert to string
877
- loc_code = legend._loc
878
- loc_names = {
879
- 0: "best",
880
- 1: "upper right",
881
- 2: "upper left",
882
- 3: "lower left",
883
- 4: "lower right",
884
- 5: "right",
885
- 6: "center left",
886
- 7: "center right",
887
- 8: "lower center",
888
- 9: "upper center",
889
- 10: "center",
890
- }
891
- info["loc"] = loc_names.get(loc_code, "best")
892
-
893
- # Check for bbox_to_anchor (custom position)
894
- bbox = legend.get_bbox_to_anchor()
895
- if bbox is not None:
896
- # Get coordinates from bbox
897
- try:
898
- bounds = bbox.bounds
899
- if bounds[0] != 0 or bounds[1] != 0:
900
- info["loc"] = "custom"
901
- info["x"] = bounds[0]
902
- info["y"] = bounds[1]
903
- except Exception:
904
- pass
905
-
906
- return jsonify(info)
907
-
908
- @app.route("/update_call", methods=["POST"])
909
- def update_call():
910
- """Update a call's kwargs and re-render.
911
-
912
- Only display kwargs are editable (orientation, colors, etc.).
913
- Data (x, y arrays) remains read-only for scientific integrity.
914
- """
915
- from .._reproducer import reproduce_from_record
916
-
917
- data = request.get_json() or {}
918
- call_id = data.get("call_id")
919
- param = data.get("param")
920
- value = data.get("value")
921
-
922
- if not call_id or not param:
923
- return jsonify({"error": "Missing call_id or param"}), 400
924
-
925
- # Find and update the call in the record
926
- updated = False
927
- if hasattr(editor.fig, "record"):
928
- for ax_key, ax_record in editor.fig.record.axes.items():
929
- for call in ax_record.calls:
930
- if call.id == call_id:
931
- # Track the override in style_overrides
932
- editor.style_overrides.set_call_override(
933
- call_id, param, value
934
- )
935
-
936
- # Update the kwarg in the record
937
- if value is None or value == "" or value == "null":
938
- call.kwargs.pop(param, None)
939
- else:
940
- call.kwargs[param] = value
941
- updated = True
942
- break
943
- if updated:
944
- break
945
-
946
- if not updated:
947
- return jsonify({"error": f"Call {call_id} not found"}), 404
948
-
949
- # Re-reproduce the figure from the updated record
950
- try:
951
- new_fig, new_axes = reproduce_from_record(editor.fig.record)
952
-
953
- # Apply style overrides to the new figure
954
- effective_style = editor.get_effective_style()
955
- base64_img, bboxes, img_size = _render_with_overrides(
956
- new_fig,
957
- effective_style if effective_style else None,
958
- editor.dark_mode,
959
- )
960
-
961
- # Update editor's figure reference
962
- editor.fig = new_fig
963
-
964
- # Reload hitmap and color map
965
- from ._hitmap import hitmap_to_base64
966
-
967
- hitmap_img, color_map = generate_hitmap(
968
- new_fig, img_size[0], img_size[1]
969
- )
970
- editor._color_map = color_map
971
- editor._hitmap_base64 = hitmap_to_base64(hitmap_img)
972
- editor._hitmap_generated = True
973
-
974
- except Exception as e:
975
- import traceback
976
-
977
- traceback.print_exc()
978
- return jsonify({"error": f"Re-render failed: {str(e)}"}), 500
979
-
980
- return jsonify(
981
- {
982
- "success": True,
983
- "image": base64_img,
984
- "bboxes": bboxes,
985
- "img_size": {"width": img_size[0], "height": img_size[1]},
986
- "call_id": call_id,
987
- "param": param,
988
- "value": value,
989
- "has_call_overrides": editor.style_overrides.has_call_overrides(),
990
- }
991
- )
992
-
993
- @app.route("/download/<fmt>")
994
- def download(fmt: str):
995
- """Download figure in specified format.
996
-
997
- Note: Downloads always use light mode for scientific document compatibility.
998
- Transparent backgrounds are preserved.
999
- """
1000
- import io
1001
-
1002
- fmt = fmt.lower()
1003
- if fmt not in ("png", "svg", "pdf"):
1004
- return jsonify({"error": f"Unsupported format: {fmt}"}), 400
1005
-
1006
- # Use effective style (base + programmatic + manual)
1007
- effective_style = editor.get_effective_style()
1008
- # Always use light mode for scientific documents (dark_mode=False)
1009
- content = render_download(
1010
- editor.fig,
1011
- fmt=fmt,
1012
- dpi=300,
1013
- overrides=effective_style if effective_style else None,
1014
- dark_mode=False, # Scientific documents require light mode
1015
- )
1016
-
1017
- mimetype = {
1018
- "png": "image/png",
1019
- "svg": "image/svg+xml",
1020
- "pdf": "application/pdf",
1021
- }[fmt]
1022
-
1023
- filename = f"figure.{fmt}"
1024
- if editor.recipe_path:
1025
- filename = f"{editor.recipe_path.stem}.{fmt}"
1026
-
1027
- return send_file(
1028
- io.BytesIO(content),
1029
- mimetype=mimetype,
1030
- as_attachment=True,
1031
- download_name=filename,
1032
- )
1033
-
1034
- @app.route("/shutdown", methods=["POST"])
1035
- def shutdown():
1036
- """Shutdown the server."""
1037
- func = request.environ.get("werkzeug.server.shutdown")
1038
- if func:
1039
- func()
1040
- return jsonify({"success": True})
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)
1041
229
 
1042
230
  # Start server
1043
231
  url = f"http://127.0.0.1:{self.port}"
1044
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")
1045
236
  print("Press Ctrl+C to stop and return overrides")
1046
237
 
1047
238
  if open_browser:
1048
239
  webbrowser.open(url)
1049
240
 
1050
241
  try:
1051
- app.run(host="127.0.0.1", port=self.port, debug=False, use_reloader=False)
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
+ )
1052
252
  except KeyboardInterrupt:
1053
253
  print("\nEditor closed")
1054
254
 
1055
255
  return self.overrides
1056
256
 
1057
257
 
1058
- def _get_form_values_from_style(style: Dict[str, Any]) -> Dict[str, Any]:
1059
- """Extract form field values from a style dictionary.
1060
-
1061
- Maps style dictionary values to HTML form input IDs.
1062
-
1063
- Parameters
1064
- ----------
1065
- style : dict
1066
- Style configuration dictionary
1067
-
1068
- Returns
1069
- -------
1070
- dict
1071
- Mapping of form input IDs to values
1072
- """
1073
- values = {}
1074
-
1075
- # Axes dimensions
1076
- if "axes" in style:
1077
- values["axes_width_mm"] = style["axes"].get("width_mm", 80)
1078
- values["axes_height_mm"] = style["axes"].get("height_mm", 55)
1079
- values["axes_thickness_mm"] = style["axes"].get("thickness_mm", 0.2)
1080
-
1081
- # Margins
1082
- if "margins" in style:
1083
- values["margins_left_mm"] = style["margins"].get("left_mm", 12)
1084
- values["margins_right_mm"] = style["margins"].get("right_mm", 3)
1085
- values["margins_bottom_mm"] = style["margins"].get("bottom_mm", 10)
1086
- values["margins_top_mm"] = style["margins"].get("top_mm", 3)
1087
-
1088
- # Spacing
1089
- if "spacing" in style:
1090
- values["spacing_horizontal_mm"] = style["spacing"].get("horizontal_mm", 8)
1091
- values["spacing_vertical_mm"] = style["spacing"].get("vertical_mm", 8)
1092
-
1093
- # Fonts
1094
- if "fonts" in style:
1095
- values["fonts_family"] = style["fonts"].get("family", "Arial")
1096
- values["fonts_axis_label_pt"] = style["fonts"].get("axis_label_pt", 7)
1097
- values["fonts_tick_label_pt"] = style["fonts"].get("tick_label_pt", 6)
1098
- values["fonts_title_pt"] = style["fonts"].get("title_pt", 8)
1099
- values["fonts_legend_pt"] = style["fonts"].get("legend_pt", 6)
1100
-
1101
- # Ticks
1102
- if "ticks" in style:
1103
- values["ticks_length_mm"] = style["ticks"].get("length_mm", 1.0)
1104
- values["ticks_thickness_mm"] = style["ticks"].get("thickness_mm", 0.2)
1105
- values["ticks_direction"] = style["ticks"].get("direction", "out")
1106
-
1107
- # Lines
1108
- if "lines" in style:
1109
- values["lines_trace_mm"] = style["lines"].get("trace_mm", 0.2)
1110
-
1111
- # Markers
1112
- if "markers" in style:
1113
- values["markers_size_mm"] = style["markers"].get("size_mm", 0.8)
1114
-
1115
- # Output
1116
- if "output" in style:
1117
- values["output_dpi"] = style["output"].get("dpi", 300)
1118
-
1119
- # Behavior
1120
- if "behavior" in style:
1121
- values["behavior_hide_top_spine"] = style["behavior"].get(
1122
- "hide_top_spine", True
1123
- )
1124
- values["behavior_hide_right_spine"] = style["behavior"].get(
1125
- "hide_right_spine", True
1126
- )
1127
- values["behavior_grid"] = style["behavior"].get("grid", False)
1128
-
1129
- # Legend
1130
- if "legend" in style:
1131
- values["legend_frameon"] = style["legend"].get("frameon", True)
1132
-
1133
- return values
1134
-
1135
-
1136
- def _render_with_overrides(
1137
- fig, overrides: Optional[Dict[str, Any]], dark_mode: bool = False
1138
- ):
1139
- """
1140
- Re-render figure with overrides applied directly.
1141
-
1142
- Applies style overrides directly to the existing figure for reliable rendering.
1143
- """
1144
- import base64
1145
- import io
1146
- import warnings
1147
-
1148
- from matplotlib.backends.backend_agg import FigureCanvasAgg
1149
- from PIL import Image
1150
-
1151
- from ._bbox import extract_bboxes
1152
- from ._renderer import _apply_dark_mode, _apply_overrides
1153
-
1154
- # Get the underlying matplotlib figure
1155
- new_fig = fig.fig if hasattr(fig, "fig") else fig
1156
-
1157
- # Safety check: validate figure size before rendering
1158
- fig_width, fig_height = new_fig.get_size_inches()
1159
- dpi = 150
1160
- pixel_width = fig_width * dpi
1161
- pixel_height = fig_height * dpi
1162
-
1163
- # Sanity check: prevent enormous figures (max 10000x10000 pixels)
1164
- MAX_PIXELS = 10000
1165
- if pixel_width > MAX_PIXELS or pixel_height > MAX_PIXELS:
1166
- # Reset to reasonable size
1167
- new_fig.set_size_inches(
1168
- min(fig_width, MAX_PIXELS / dpi), min(fig_height, MAX_PIXELS / dpi)
1169
- )
1170
-
1171
- # Switch to Agg backend to avoid Tkinter thread issues
1172
- new_fig.set_canvas(FigureCanvasAgg(new_fig))
1173
-
1174
- # Disable constrained_layout if present (can cause rendering issues on repeated calls)
1175
- # Store original state to restore later
1176
- layout_engine = new_fig.get_layout_engine()
1177
- if layout_engine is not None and hasattr(layout_engine, "__class__"):
1178
- layout_name = layout_engine.__class__.__name__
1179
- if "Constrained" in layout_name:
1180
- new_fig.set_layout_engine("none")
1181
-
1182
- # Apply overrides directly to existing figure
1183
- if overrides:
1184
- _apply_overrides(new_fig, overrides)
1185
-
1186
- # Apply dark mode if requested
1187
- if dark_mode:
1188
- _apply_dark_mode(new_fig)
1189
-
1190
- # Validate axes bounds before rendering (prevent infinite/invalid extents)
1191
- for ax in new_fig.get_axes():
1192
- xlim = ax.get_xlim()
1193
- ylim = ax.get_ylim()
1194
- # Check for invalid limits (inf, nan, or extremely large)
1195
- if any(not (-1e10 < v < 1e10) for v in xlim + ylim):
1196
- ax.set_xlim(-1, 1)
1197
- ax.set_ylim(-1, 1)
1198
-
1199
- # Save to PNG using same params as static save
1200
- # Catch constrained_layout warnings and handle gracefully
1201
- buf = io.BytesIO()
1202
- with warnings.catch_warnings():
1203
- warnings.filterwarnings("ignore", "constrained_layout not applied")
1204
- try:
1205
- new_fig.savefig(buf, format="png", dpi=150, bbox_inches="tight")
1206
- except (MemoryError, ValueError):
1207
- # Fall back to saving without bbox_inches="tight"
1208
- buf = io.BytesIO()
1209
- new_fig.savefig(buf, format="png", dpi=150)
1210
- buf.seek(0)
1211
- png_bytes = buf.read()
1212
- base64_str = base64.b64encode(png_bytes).decode("utf-8")
1213
-
1214
- # Get image size
1215
- buf.seek(0)
1216
- img = Image.open(buf)
1217
- img_size = img.size
1218
-
1219
- # Extract bboxes
1220
- original_dpi = new_fig.dpi
1221
- new_fig.set_dpi(150)
1222
- new_fig.canvas.draw()
1223
- bboxes = extract_bboxes(new_fig, img_size[0], img_size[1])
1224
- new_fig.set_dpi(original_dpi)
1225
-
1226
- return base64_str, bboxes, img_size
1227
-
1228
-
1229
258
  __all__ = ["FigureEditor"]