figrecipe 0.6.0__py3-none-any.whl → 0.9.0__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 (269) hide show
  1. figrecipe/__init__.py +161 -1030
  2. figrecipe/__main__.py +12 -0
  3. figrecipe/_api/__init__.py +48 -0
  4. figrecipe/_api/_extract.py +108 -0
  5. figrecipe/_api/_notebook.py +61 -0
  6. figrecipe/_api/_panel.py +113 -0
  7. figrecipe/_api/_save.py +287 -0
  8. figrecipe/_api/_seaborn_proxy.py +34 -0
  9. figrecipe/_api/_style_manager.py +153 -0
  10. figrecipe/_api/_subplots.py +333 -0
  11. figrecipe/_api/_validate.py +82 -0
  12. figrecipe/_cli/__init__.py +7 -0
  13. figrecipe/_cli/_compose.py +87 -0
  14. figrecipe/_cli/_convert.py +117 -0
  15. figrecipe/_cli/_crop.py +82 -0
  16. figrecipe/_cli/_edit.py +70 -0
  17. figrecipe/_cli/_extract.py +128 -0
  18. figrecipe/_cli/_fonts.py +47 -0
  19. figrecipe/_cli/_info.py +67 -0
  20. figrecipe/_cli/_main.py +58 -0
  21. figrecipe/_cli/_reproduce.py +79 -0
  22. figrecipe/_cli/_style.py +77 -0
  23. figrecipe/_cli/_validate.py +66 -0
  24. figrecipe/_cli/_version.py +50 -0
  25. figrecipe/_composition/__init__.py +32 -0
  26. figrecipe/_composition/_alignment.py +452 -0
  27. figrecipe/_composition/_compose.py +179 -0
  28. figrecipe/_composition/_import_axes.py +127 -0
  29. figrecipe/_composition/_visibility.py +125 -0
  30. figrecipe/_dev/__init__.py +4 -93
  31. figrecipe/_dev/_plotters.py +76 -0
  32. figrecipe/_dev/_run_demos.py +56 -0
  33. figrecipe/_dev/browser/__init__.py +69 -0
  34. figrecipe/_dev/browser/_audio.py +240 -0
  35. figrecipe/_dev/browser/_caption.py +356 -0
  36. figrecipe/_dev/browser/_click_effect.py +146 -0
  37. figrecipe/_dev/browser/_cursor.py +196 -0
  38. figrecipe/_dev/browser/_highlight.py +105 -0
  39. figrecipe/_dev/browser/_narration.py +237 -0
  40. figrecipe/_dev/browser/_recorder.py +446 -0
  41. figrecipe/_dev/browser/_utils.py +178 -0
  42. figrecipe/_dev/browser/_video_trim/__init__.py +152 -0
  43. figrecipe/_dev/browser/_video_trim/_detection.py +223 -0
  44. figrecipe/_dev/browser/_video_trim/_markers.py +140 -0
  45. figrecipe/_dev/demo_plotters/__init__.py +35 -166
  46. figrecipe/_dev/demo_plotters/_categories.py +81 -0
  47. figrecipe/_dev/demo_plotters/_figure_creators.py +119 -0
  48. figrecipe/_dev/demo_plotters/_helpers.py +31 -0
  49. figrecipe/_dev/demo_plotters/_registry.py +50 -0
  50. figrecipe/_dev/demo_plotters/bar_categorical/__init__.py +4 -0
  51. figrecipe/_dev/demo_plotters/contour_surface/__init__.py +4 -0
  52. figrecipe/_dev/demo_plotters/distribution/__init__.py +4 -0
  53. figrecipe/_dev/demo_plotters/image_matrix/__init__.py +4 -0
  54. figrecipe/_dev/demo_plotters/line_curve/__init__.py +4 -0
  55. figrecipe/_dev/demo_plotters/{plot_plot.py → line_curve/plot_plot.py} +3 -2
  56. figrecipe/_dev/demo_plotters/scatter_points/__init__.py +4 -0
  57. figrecipe/_dev/demo_plotters/special/__init__.py +4 -0
  58. figrecipe/_dev/demo_plotters/{plot_pie.py → special/plot_pie.py} +5 -1
  59. figrecipe/_dev/demo_plotters/spectral_signal/__init__.py +4 -0
  60. figrecipe/_dev/demo_plotters/vector_flow/__init__.py +4 -0
  61. figrecipe/_editor/__init__.py +61 -13
  62. figrecipe/_editor/_bbox/__init__.py +43 -0
  63. figrecipe/_editor/_bbox/_collections.py +177 -0
  64. figrecipe/_editor/_bbox/_elements.py +159 -0
  65. figrecipe/_editor/_bbox/_extract.py +402 -0
  66. figrecipe/_editor/_bbox/_extract_axes.py +370 -0
  67. figrecipe/_editor/_bbox/_extract_text.py +466 -0
  68. figrecipe/_editor/_bbox/_lines.py +173 -0
  69. figrecipe/_editor/_bbox/_transforms.py +146 -0
  70. figrecipe/_editor/_call_overrides.py +183 -0
  71. figrecipe/_editor/_datatable_plot_handlers.py +249 -0
  72. figrecipe/_editor/_figure_layout.py +211 -0
  73. figrecipe/_editor/_flask_app.py +200 -1030
  74. figrecipe/_editor/_helpers.py +251 -0
  75. figrecipe/_editor/_hitmap/__init__.py +76 -0
  76. figrecipe/_editor/_hitmap/_artists/__init__.py +21 -0
  77. figrecipe/_editor/_hitmap/_artists/_collections.py +345 -0
  78. figrecipe/_editor/_hitmap/_artists/_images.py +68 -0
  79. figrecipe/_editor/_hitmap/_artists/_lines.py +107 -0
  80. figrecipe/_editor/_hitmap/_artists/_patches.py +163 -0
  81. figrecipe/_editor/_hitmap/_artists/_text.py +190 -0
  82. figrecipe/_editor/_hitmap/_colors.py +181 -0
  83. figrecipe/_editor/_hitmap/_detect.py +194 -0
  84. figrecipe/_editor/_hitmap/_restore.py +154 -0
  85. figrecipe/_editor/_hitmap_main.py +182 -0
  86. figrecipe/_editor/_overrides.py +4 -1
  87. figrecipe/_editor/_plot_types_registry.py +190 -0
  88. figrecipe/_editor/_preferences.py +135 -0
  89. figrecipe/_editor/_render_overrides.py +507 -0
  90. figrecipe/_editor/_renderer.py +81 -186
  91. figrecipe/_editor/_routes_annotation.py +114 -0
  92. figrecipe/_editor/_routes_axis.py +482 -0
  93. figrecipe/_editor/_routes_captions.py +130 -0
  94. figrecipe/_editor/_routes_composition.py +270 -0
  95. figrecipe/_editor/_routes_core.py +126 -0
  96. figrecipe/_editor/_routes_datatable.py +364 -0
  97. figrecipe/_editor/_routes_element.py +335 -0
  98. figrecipe/_editor/_routes_files.py +443 -0
  99. figrecipe/_editor/_routes_image.py +200 -0
  100. figrecipe/_editor/_routes_snapshot.py +94 -0
  101. figrecipe/_editor/_routes_style.py +243 -0
  102. figrecipe/_editor/_templates/__init__.py +116 -1
  103. figrecipe/_editor/_templates/_html.py +154 -64
  104. figrecipe/_editor/_templates/_html_components/__init__.py +13 -0
  105. figrecipe/_editor/_templates/_html_components/_composition_toolbar.py +79 -0
  106. figrecipe/_editor/_templates/_html_components/_file_browser.py +41 -0
  107. figrecipe/_editor/_templates/_html_datatable.py +92 -0
  108. figrecipe/_editor/_templates/_scripts/__init__.py +178 -0
  109. figrecipe/_editor/_templates/_scripts/_accordion.py +328 -0
  110. figrecipe/_editor/_templates/_scripts/_annotation_drag.py +504 -0
  111. figrecipe/_editor/_templates/_scripts/_api.py +228 -0
  112. figrecipe/_editor/_templates/_scripts/_canvas_context_menu.py +182 -0
  113. figrecipe/_editor/_templates/_scripts/_captions.py +231 -0
  114. figrecipe/_editor/_templates/_scripts/_colors.py +485 -0
  115. figrecipe/_editor/_templates/_scripts/_composition.py +283 -0
  116. figrecipe/_editor/_templates/_scripts/_core.py +493 -0
  117. figrecipe/_editor/_templates/_scripts/_datatable/__init__.py +59 -0
  118. figrecipe/_editor/_templates/_scripts/_datatable/_cell_edit.py +97 -0
  119. figrecipe/_editor/_templates/_scripts/_datatable/_clipboard.py +164 -0
  120. figrecipe/_editor/_templates/_scripts/_datatable/_context_menu.py +221 -0
  121. figrecipe/_editor/_templates/_scripts/_datatable/_core.py +150 -0
  122. figrecipe/_editor/_templates/_scripts/_datatable/_editable.py +511 -0
  123. figrecipe/_editor/_templates/_scripts/_datatable/_import.py +161 -0
  124. figrecipe/_editor/_templates/_scripts/_datatable/_plot.py +261 -0
  125. figrecipe/_editor/_templates/_scripts/_datatable/_selection.py +438 -0
  126. figrecipe/_editor/_templates/_scripts/_datatable/_table.py +256 -0
  127. figrecipe/_editor/_templates/_scripts/_datatable/_tabs.py +354 -0
  128. figrecipe/_editor/_templates/_scripts/_debug_snapshot.py +186 -0
  129. figrecipe/_editor/_templates/_scripts/_element_editor.py +325 -0
  130. figrecipe/_editor/_templates/_scripts/_files.py +429 -0
  131. figrecipe/_editor/_templates/_scripts/_files_context_menu.py +240 -0
  132. figrecipe/_editor/_templates/_scripts/_hitmap.py +512 -0
  133. figrecipe/_editor/_templates/_scripts/_image_drop.py +428 -0
  134. figrecipe/_editor/_templates/_scripts/_inspector.py +315 -0
  135. figrecipe/_editor/_templates/_scripts/_labels.py +464 -0
  136. figrecipe/_editor/_templates/_scripts/_legend_drag.py +270 -0
  137. figrecipe/_editor/_templates/_scripts/_modals.py +226 -0
  138. figrecipe/_editor/_templates/_scripts/_multi_select.py +198 -0
  139. figrecipe/_editor/_templates/_scripts/_overlays.py +292 -0
  140. figrecipe/_editor/_templates/_scripts/_panel_drag.py +505 -0
  141. figrecipe/_editor/_templates/_scripts/_panel_drag_snapshot.py +33 -0
  142. figrecipe/_editor/_templates/_scripts/_panel_position.py +463 -0
  143. figrecipe/_editor/_templates/_scripts/_panel_resize.py +230 -0
  144. figrecipe/_editor/_templates/_scripts/_panel_snap.py +307 -0
  145. figrecipe/_editor/_templates/_scripts/_region_select.py +255 -0
  146. figrecipe/_editor/_templates/_scripts/_selection.py +244 -0
  147. figrecipe/_editor/_templates/_scripts/_sync.py +242 -0
  148. figrecipe/_editor/_templates/_scripts/_tabs.py +89 -0
  149. figrecipe/_editor/_templates/_scripts/_undo_redo.py +348 -0
  150. figrecipe/_editor/_templates/_scripts/_view_mode.py +107 -0
  151. figrecipe/_editor/_templates/_scripts/_zoom.py +212 -0
  152. figrecipe/_editor/_templates/_styles/__init__.py +78 -0
  153. figrecipe/_editor/_templates/_styles/_base.py +111 -0
  154. figrecipe/_editor/_templates/_styles/_buttons.py +327 -0
  155. figrecipe/_editor/_templates/_styles/_color_input.py +123 -0
  156. figrecipe/_editor/_templates/_styles/_composition.py +87 -0
  157. figrecipe/_editor/_templates/_styles/_controls.py +430 -0
  158. figrecipe/_editor/_templates/_styles/_datatable/__init__.py +40 -0
  159. figrecipe/_editor/_templates/_styles/_datatable/_editable.py +203 -0
  160. figrecipe/_editor/_templates/_styles/_datatable/_panel.py +268 -0
  161. figrecipe/_editor/_templates/_styles/_datatable/_table.py +479 -0
  162. figrecipe/_editor/_templates/_styles/_datatable/_toolbar.py +384 -0
  163. figrecipe/_editor/_templates/_styles/_datatable/_vars.py +123 -0
  164. figrecipe/_editor/_templates/_styles/_dynamic_props.py +144 -0
  165. figrecipe/_editor/_templates/_styles/_file_browser.py +466 -0
  166. figrecipe/_editor/_templates/_styles/_forms.py +224 -0
  167. figrecipe/_editor/_templates/_styles/_hitmap.py +191 -0
  168. figrecipe/_editor/_templates/_styles/_inspector.py +90 -0
  169. figrecipe/_editor/_templates/_styles/_labels.py +118 -0
  170. figrecipe/_editor/_templates/_styles/_modals.py +127 -0
  171. figrecipe/_editor/_templates/_styles/_overlays.py +130 -0
  172. figrecipe/_editor/_templates/_styles/_preview.py +430 -0
  173. figrecipe/_editor/_templates/_styles/_selection.py +73 -0
  174. figrecipe/_editor/_templates/_styles/_spinner.py +117 -0
  175. figrecipe/_editor/static/audio/click.mp3 +0 -0
  176. figrecipe/_editor/static/click.mp3 +0 -0
  177. figrecipe/_editor/static/icons/favicon.ico +0 -0
  178. figrecipe/_integrations/__init__.py +17 -0
  179. figrecipe/_integrations/_scitex_stats.py +298 -0
  180. figrecipe/_params/_DECORATION_METHODS.py +8 -0
  181. figrecipe/_recorder.py +63 -109
  182. figrecipe/_recorder_utils.py +124 -0
  183. figrecipe/_reproducer/__init__.py +18 -0
  184. figrecipe/_reproducer/_core.py +509 -0
  185. figrecipe/_reproducer/_custom_plots.py +279 -0
  186. figrecipe/_reproducer/_seaborn.py +100 -0
  187. figrecipe/_reproducer/_violin.py +186 -0
  188. figrecipe/_signatures/_kwargs.py +273 -0
  189. figrecipe/_signatures/_loader.py +21 -423
  190. figrecipe/_signatures/_parsing.py +147 -0
  191. figrecipe/_utils/__init__.py +3 -0
  192. figrecipe/_utils/_bundle.py +205 -0
  193. figrecipe/_wrappers/_axes.py +252 -895
  194. figrecipe/_wrappers/_axes_helpers.py +136 -0
  195. figrecipe/_wrappers/_axes_plots.py +418 -0
  196. figrecipe/_wrappers/_axes_seaborn.py +157 -0
  197. figrecipe/_wrappers/_caption_generator.py +218 -0
  198. figrecipe/_wrappers/_figure.py +188 -1
  199. figrecipe/_wrappers/_panel_labels.py +127 -0
  200. figrecipe/_wrappers/_plot_helpers.py +143 -0
  201. figrecipe/_wrappers/_stat_annotation.py +274 -0
  202. figrecipe/_wrappers/_violin_helpers.py +180 -0
  203. figrecipe/styles/__init__.py +8 -6
  204. figrecipe/styles/_dotdict.py +72 -0
  205. figrecipe/styles/_finalize.py +134 -0
  206. figrecipe/styles/_fonts.py +77 -0
  207. figrecipe/styles/_kwargs_converter.py +178 -0
  208. figrecipe/styles/_plot_styles.py +209 -0
  209. figrecipe/styles/_style_applier.py +42 -480
  210. figrecipe/styles/_style_loader.py +16 -192
  211. figrecipe/styles/_themes.py +151 -0
  212. figrecipe/styles/presets/MATPLOTLIB.yaml +2 -1
  213. figrecipe/styles/presets/SCITEX.yaml +40 -28
  214. figrecipe-0.9.0.dist-info/METADATA +427 -0
  215. figrecipe-0.9.0.dist-info/RECORD +277 -0
  216. figrecipe-0.9.0.dist-info/entry_points.txt +2 -0
  217. figrecipe/_editor/_bbox.py +0 -978
  218. figrecipe/_editor/_hitmap.py +0 -937
  219. figrecipe/_editor/_templates/_scripts.py +0 -2778
  220. figrecipe/_editor/_templates/_styles.py +0 -1326
  221. figrecipe/_reproducer.py +0 -975
  222. figrecipe-0.6.0.dist-info/METADATA +0 -394
  223. figrecipe-0.6.0.dist-info/RECORD +0 -90
  224. /figrecipe/_dev/demo_plotters/{plot_bar.py → bar_categorical/plot_bar.py} +0 -0
  225. /figrecipe/_dev/demo_plotters/{plot_barh.py → bar_categorical/plot_barh.py} +0 -0
  226. /figrecipe/_dev/demo_plotters/{plot_contour.py → contour_surface/plot_contour.py} +0 -0
  227. /figrecipe/_dev/demo_plotters/{plot_contourf.py → contour_surface/plot_contourf.py} +0 -0
  228. /figrecipe/_dev/demo_plotters/{plot_tricontour.py → contour_surface/plot_tricontour.py} +0 -0
  229. /figrecipe/_dev/demo_plotters/{plot_tricontourf.py → contour_surface/plot_tricontourf.py} +0 -0
  230. /figrecipe/_dev/demo_plotters/{plot_tripcolor.py → contour_surface/plot_tripcolor.py} +0 -0
  231. /figrecipe/_dev/demo_plotters/{plot_triplot.py → contour_surface/plot_triplot.py} +0 -0
  232. /figrecipe/_dev/demo_plotters/{plot_boxplot.py → distribution/plot_boxplot.py} +0 -0
  233. /figrecipe/_dev/demo_plotters/{plot_ecdf.py → distribution/plot_ecdf.py} +0 -0
  234. /figrecipe/_dev/demo_plotters/{plot_hist.py → distribution/plot_hist.py} +0 -0
  235. /figrecipe/_dev/demo_plotters/{plot_hist2d.py → distribution/plot_hist2d.py} +0 -0
  236. /figrecipe/_dev/demo_plotters/{plot_violinplot.py → distribution/plot_violinplot.py} +0 -0
  237. /figrecipe/_dev/demo_plotters/{plot_hexbin.py → image_matrix/plot_hexbin.py} +0 -0
  238. /figrecipe/_dev/demo_plotters/{plot_imshow.py → image_matrix/plot_imshow.py} +0 -0
  239. /figrecipe/_dev/demo_plotters/{plot_matshow.py → image_matrix/plot_matshow.py} +0 -0
  240. /figrecipe/_dev/demo_plotters/{plot_pcolor.py → image_matrix/plot_pcolor.py} +0 -0
  241. /figrecipe/_dev/demo_plotters/{plot_pcolormesh.py → image_matrix/plot_pcolormesh.py} +0 -0
  242. /figrecipe/_dev/demo_plotters/{plot_spy.py → image_matrix/plot_spy.py} +0 -0
  243. /figrecipe/_dev/demo_plotters/{plot_errorbar.py → line_curve/plot_errorbar.py} +0 -0
  244. /figrecipe/_dev/demo_plotters/{plot_fill.py → line_curve/plot_fill.py} +0 -0
  245. /figrecipe/_dev/demo_plotters/{plot_fill_between.py → line_curve/plot_fill_between.py} +0 -0
  246. /figrecipe/_dev/demo_plotters/{plot_fill_betweenx.py → line_curve/plot_fill_betweenx.py} +0 -0
  247. /figrecipe/_dev/demo_plotters/{plot_stackplot.py → line_curve/plot_stackplot.py} +0 -0
  248. /figrecipe/_dev/demo_plotters/{plot_stairs.py → line_curve/plot_stairs.py} +0 -0
  249. /figrecipe/_dev/demo_plotters/{plot_step.py → line_curve/plot_step.py} +0 -0
  250. /figrecipe/_dev/demo_plotters/{plot_scatter.py → scatter_points/plot_scatter.py} +0 -0
  251. /figrecipe/_dev/demo_plotters/{plot_eventplot.py → special/plot_eventplot.py} +0 -0
  252. /figrecipe/_dev/demo_plotters/{plot_loglog.py → special/plot_loglog.py} +0 -0
  253. /figrecipe/_dev/demo_plotters/{plot_semilogx.py → special/plot_semilogx.py} +0 -0
  254. /figrecipe/_dev/demo_plotters/{plot_semilogy.py → special/plot_semilogy.py} +0 -0
  255. /figrecipe/_dev/demo_plotters/{plot_stem.py → special/plot_stem.py} +0 -0
  256. /figrecipe/_dev/demo_plotters/{plot_acorr.py → spectral_signal/plot_acorr.py} +0 -0
  257. /figrecipe/_dev/demo_plotters/{plot_angle_spectrum.py → spectral_signal/plot_angle_spectrum.py} +0 -0
  258. /figrecipe/_dev/demo_plotters/{plot_cohere.py → spectral_signal/plot_cohere.py} +0 -0
  259. /figrecipe/_dev/demo_plotters/{plot_csd.py → spectral_signal/plot_csd.py} +0 -0
  260. /figrecipe/_dev/demo_plotters/{plot_magnitude_spectrum.py → spectral_signal/plot_magnitude_spectrum.py} +0 -0
  261. /figrecipe/_dev/demo_plotters/{plot_phase_spectrum.py → spectral_signal/plot_phase_spectrum.py} +0 -0
  262. /figrecipe/_dev/demo_plotters/{plot_psd.py → spectral_signal/plot_psd.py} +0 -0
  263. /figrecipe/_dev/demo_plotters/{plot_specgram.py → spectral_signal/plot_specgram.py} +0 -0
  264. /figrecipe/_dev/demo_plotters/{plot_xcorr.py → spectral_signal/plot_xcorr.py} +0 -0
  265. /figrecipe/_dev/demo_plotters/{plot_barbs.py → vector_flow/plot_barbs.py} +0 -0
  266. /figrecipe/_dev/demo_plotters/{plot_quiver.py → vector_flow/plot_quiver.py} +0 -0
  267. /figrecipe/_dev/demo_plotters/{plot_streamplot.py → vector_flow/plot_streamplot.py} +0 -0
  268. {figrecipe-0.6.0.dist-info → figrecipe-0.9.0.dist-info}/WHEEL +0 -0
  269. {figrecipe-0.6.0.dist-info → figrecipe-0.9.0.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__(
@@ -54,9 +51,13 @@ class FigureEditor:
54
51
  recipe_path: Optional[Path] = None,
55
52
  style: Optional[Dict[str, Any]] = None,
56
53
  port: int = 5050,
54
+ host: str = "127.0.0.1",
57
55
  static_png_path: Optional[Path] = None,
58
56
  hitmap_base64: Optional[str] = None,
59
57
  color_map: Optional[Dict] = None,
58
+ hot_reload: bool = False,
59
+ working_dir: Optional[Path] = None,
60
+ desktop: bool = False,
60
61
  ):
61
62
  """
62
63
  Initialize figure editor.
@@ -77,11 +78,26 @@ class FigureEditor:
77
78
  Pre-generated hitmap as base64.
78
79
  color_map : dict, optional
79
80
  Pre-generated color map for hitmap.
81
+ hot_reload : bool, optional
82
+ Enable hot reload - server restarts on source file changes.
83
+ working_dir : Path, optional
84
+ Working directory for file switching (default: current directory).
85
+ desktop : bool, optional
86
+ Launch as native desktop window using pywebview.
80
87
  """
81
88
  self.fig = fig
89
+ self.desktop = desktop
82
90
  self.recipe_path = Path(recipe_path) if recipe_path else None
83
91
  self.port = port
84
- self.dark_mode = False
92
+ self.host = host
93
+ self.hot_reload = hot_reload
94
+ self.working_dir = Path(working_dir) if working_dir else Path.cwd()
95
+
96
+ # Load user preferences
97
+ from ._preferences import load_preferences
98
+
99
+ prefs = load_preferences()
100
+ self.dark_mode = prefs.get("dark_mode", False)
85
101
 
86
102
  # Pre-rendered static PNG (source of truth)
87
103
  self._static_png_path = static_png_path
@@ -92,12 +108,14 @@ class FigureEditor:
92
108
  with open(static_png_path, "rb") as f:
93
109
  self._initial_base64 = base64.b64encode(f.read()).decode("utf-8")
94
110
 
95
- # Initialize style overrides system
111
+ # Initialize style overrides system (captures original positions into base_style)
96
112
  self._init_style_overrides(style)
97
113
 
98
114
  # Pre-generated hitmap and color_map
115
+ # Use empty dict as default to prevent JavaScript errors
116
+ # when page loads before hitmap is generated
99
117
  self._hitmap_base64 = hitmap_base64
100
- self._color_map = color_map
118
+ self._color_map = color_map if color_map is not None else {}
101
119
 
102
120
  def _init_style_overrides(self, programmatic_style: Optional[Dict[str, Any]]):
103
121
  """Initialize the layered style override system."""
@@ -106,14 +124,15 @@ class FigureEditor:
106
124
  existing = load_overrides(self.recipe_path)
107
125
  if existing:
108
126
  self.style_overrides = existing
109
- # Update programmatic style if provided
110
127
  if programmatic_style:
111
128
  self.style_overrides.programmatic_style = programmatic_style
129
+ # Ensure original positions are captured even when loading existing overrides
130
+ self._ensure_original_positions_in_base_style()
112
131
  return
113
132
 
114
- # Get base style from global preset (always ensure we have a base style)
133
+ # Get base style from global preset
115
134
  base_style = {}
116
- style_name = "SCITEX" # Default
135
+ style_name = "SCITEX"
117
136
  try:
118
137
  from ..styles._style_loader import (
119
138
  _CURRENT_STYLE_NAME,
@@ -122,11 +141,9 @@ class FigureEditor:
122
141
  to_subplots_kwargs,
123
142
  )
124
143
 
125
- # If no style is loaded, load the default SCITEX style
126
144
  if _STYLE_CACHE is None:
127
145
  load_style("SCITEX")
128
146
 
129
- # Get the style cache (now guaranteed to exist)
130
147
  from ..styles._style_loader import _STYLE_CACHE
131
148
 
132
149
  if _STYLE_CACHE is not None:
@@ -135,10 +152,18 @@ class FigureEditor:
135
152
  except Exception:
136
153
  pass
137
154
 
138
- # Store the style name for UI display
139
155
  self._style_name = style_name
140
156
 
141
- # Create new overrides
157
+ # Capture original annotation positions into base_style for restore
158
+ annotation_positions = self._capture_annotation_positions()
159
+ for key, pos_data in annotation_positions.items():
160
+ base_style[f"_original_{key}"] = pos_data
161
+
162
+ # Capture original axes positions into base_style for restore
163
+ axes_positions = self._capture_axes_positions()
164
+ for ax_idx, pos in axes_positions.items():
165
+ base_style[f"_original_axes_position_{ax_idx}"] = pos
166
+
142
167
  self.style_overrides = create_overrides_from_style(
143
168
  base_style=base_style,
144
169
  programmatic_style=programmatic_style or {},
@@ -163,6 +188,75 @@ class FigureEditor:
163
188
  """Get the final merged style."""
164
189
  return self.style_overrides.get_effective_style()
165
190
 
191
+ def _ensure_original_positions_in_base_style(self) -> None:
192
+ """Ensure original positions are captured in base_style (for existing overrides)."""
193
+ base_style = self.style_overrides.base_style
194
+
195
+ # Add annotation positions if not present
196
+ annotation_positions = self._capture_annotation_positions()
197
+ for key, pos_data in annotation_positions.items():
198
+ base_key = f"_original_{key}"
199
+ if base_key not in base_style:
200
+ base_style[base_key] = pos_data
201
+
202
+ # Add axes positions if not present
203
+ axes_positions = self._capture_axes_positions()
204
+ for ax_idx, pos in axes_positions.items():
205
+ base_key = f"_original_axes_position_{ax_idx}"
206
+ if base_key not in base_style:
207
+ base_style[base_key] = pos
208
+
209
+ def _capture_axes_positions(self) -> Dict[int, list]:
210
+ """Capture current axes positions (matplotlib coords: [left, bottom, width, height])."""
211
+ mpl_fig = self.fig.fig if hasattr(self.fig, "fig") else self.fig
212
+ axes = mpl_fig.get_axes()
213
+ positions = {}
214
+ for i, ax in enumerate(axes):
215
+ bbox = ax.get_position()
216
+ positions[i] = [bbox.x0, bbox.y0, bbox.width, bbox.height]
217
+ return positions
218
+
219
+ def restore_axes_positions(self) -> None:
220
+ """Restore axes to their original positions from base_style."""
221
+ base_style = self.style_overrides.base_style
222
+ mpl_fig = self.fig.fig if hasattr(self.fig, "fig") else self.fig
223
+ axes = mpl_fig.get_axes()
224
+ for i, ax in enumerate(axes):
225
+ key = f"_original_axes_position_{i}"
226
+ if key in base_style:
227
+ pos = base_style[key]
228
+ ax.set_position(pos)
229
+
230
+ def _capture_annotation_positions(self) -> Dict[str, dict]:
231
+ """Capture current annotation (text) positions for each axis."""
232
+ mpl_fig = self.fig.fig if hasattr(self.fig, "fig") else self.fig
233
+ axes = mpl_fig.get_axes()
234
+ positions = {}
235
+ for ax_idx, ax in enumerate(axes):
236
+ for text_idx, text_obj in enumerate(ax.texts):
237
+ key = f"ax{ax_idx}_text{text_idx}"
238
+ pos = text_obj.get_position()
239
+ positions[key] = {
240
+ "position": [
241
+ float(pos[0]),
242
+ float(pos[1]),
243
+ ], # Convert to float for JSON
244
+ "transform_is_axes": bool(text_obj.get_transform() == ax.transAxes),
245
+ }
246
+ return positions
247
+
248
+ def restore_annotation_positions(self) -> None:
249
+ """Restore annotations to their original positions from base_style."""
250
+ base_style = self.style_overrides.base_style
251
+ mpl_fig = self.fig.fig if hasattr(self.fig, "fig") else self.fig
252
+ axes = mpl_fig.get_axes()
253
+ for ax_idx, ax in enumerate(axes):
254
+ for text_idx, text_obj in enumerate(ax.texts):
255
+ key = f"_original_ax{ax_idx}_text{text_idx}"
256
+ if key in base_style:
257
+ orig = base_style[key]
258
+ text_obj.set_position(tuple(orig["position"]))
259
+
166
260
  def run(self, open_browser: bool = True) -> Dict[str, Any]:
167
261
  """
168
262
  Run the editor server.
@@ -177,1053 +271,129 @@ class FigureEditor:
177
271
  dict
178
272
  Final style overrides after editing session.
179
273
  """
180
- from flask import Flask, jsonify, render_template_string, request, send_file
274
+ from flask import Flask
181
275
 
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
276
+ from ._routes_annotation import register_annotation_routes
277
+ from ._routes_axis import register_axis_routes
186
278
 
187
- # Use specified port strictly (no fallback)
279
+ # DISABLED: Snapshot feature corrupts figure state via visibility changes
280
+ # from ._routes_snapshot import register_snapshot_routes
281
+ from ._routes_captions import register_caption_routes
282
+ from ._routes_core import register_core_routes
283
+ from ._routes_datatable import register_datatable_routes
284
+ from ._routes_element import register_element_routes
285
+ from ._routes_files import register_file_routes
286
+ from ._routes_image import register_image_routes
287
+ from ._routes_style import register_style_routes
188
288
 
189
289
  # Defer hitmap generation until first request (lazy loading)
190
- # This makes the editor start immediately
191
290
  self._hitmap_generated = self._hitmap_base64 is not None
192
291
 
193
- # Create Flask app
194
- 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
-
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
- )
292
+ # Create Flask app with static folder for assets (click sounds, etc.)
293
+ static_folder = Path(__file__).parent / "static"
294
+ app = Flask(
295
+ __name__, static_folder=str(static_folder), static_url_path="/static"
296
+ )
992
297
 
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
- )
298
+ # Register all routes
299
+ register_core_routes(app, self)
300
+ register_file_routes(app, self)
301
+ register_style_routes(app, self)
302
+ register_axis_routes(app, self)
303
+ register_element_routes(app, self)
304
+ register_image_routes(app, self)
305
+ register_datatable_routes(app, self)
306
+ register_annotation_routes(app, self)
307
+ register_caption_routes(app, self)
308
+ # DISABLED: register_snapshot_routes(app, self)
1016
309
 
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
- )
310
+ # Start server
311
+ url = f"http://{self.host}:{self.port}"
1033
312
 
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})
313
+ if self.desktop:
314
+ # Desktop mode using pywebview
315
+ return self._run_desktop(app, url)
316
+ else:
317
+ # Browser mode
318
+ return self._run_browser(app, url, open_browser)
1041
319
 
1042
- # Start server
1043
- url = f"http://127.0.0.1:{self.port}"
320
+ def _run_browser(self, app, url: str, open_browser: bool) -> Dict[str, Any]:
321
+ """Run editor in browser mode."""
1044
322
  print(f"Figure Editor running at {url}")
323
+
324
+ if self.hot_reload:
325
+ print("Hot reload ENABLED - server will restart on source file changes")
1045
326
  print("Press Ctrl+C to stop and return overrides")
1046
327
 
1047
328
  if open_browser:
1048
329
  webbrowser.open(url)
1049
330
 
1050
331
  try:
1051
- app.run(host="127.0.0.1", port=self.port, debug=False, use_reloader=False)
332
+ app.run(
333
+ host=self.host,
334
+ port=self.port,
335
+ debug=False,
336
+ use_reloader=False,
337
+ threaded=True,
338
+ )
1052
339
  except KeyboardInterrupt:
1053
340
  print("\nEditor closed")
1054
341
 
1055
342
  return self.overrides
1056
343
 
344
+ def _run_desktop(self, app, url: str) -> Dict[str, Any]:
345
+ """Run editor as native desktop window using pywebview."""
346
+ try:
347
+ import webview
348
+ except ImportError:
349
+ raise ImportError(
350
+ "pywebview is required for desktop mode. "
351
+ "Install with: pip install figrecipe[desktop]"
352
+ )
1057
353
 
1058
- def _get_form_values_from_style(style: Dict[str, Any]) -> Dict[str, Any]:
1059
- """Extract form field values from a style dictionary.
354
+ import threading
1060
355
 
1061
- Maps style dictionary values to HTML form input IDs.
356
+ print("Figure Editor (Desktop Mode)")
357
+ print("Close the window to stop and return overrides")
1062
358
 
1063
- Parameters
1064
- ----------
1065
- style : dict
1066
- Style configuration dictionary
359
+ # Start Flask in a background thread
360
+ def run_flask():
361
+ import logging
1067
362
 
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)
363
+ # Suppress Flask logging in desktop mode
364
+ log = logging.getLogger("werkzeug")
365
+ log.setLevel(logging.ERROR)
366
+ app.run(
367
+ host=self.host,
368
+ port=self.port,
369
+ debug=False,
370
+ use_reloader=False,
371
+ threaded=True,
372
+ )
1132
373
 
1133
- return values
374
+ flask_thread = threading.Thread(target=run_flask, daemon=True)
375
+ flask_thread.start()
1134
376
 
377
+ # Wait briefly for Flask to start
378
+ import time
1135
379
 
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.
380
+ time.sleep(0.5)
1141
381
 
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)
382
+ # Create native window (variable needed for pywebview lifecycle)
383
+ _window = webview.create_window(
384
+ title="FigRecipe Editor",
385
+ url=url,
386
+ width=1400,
387
+ height=900,
388
+ resizable=True,
389
+ min_size=(800, 600),
1169
390
  )
1170
391
 
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
392
+ # Start webview (blocks until window is closed)
393
+ webview.start()
394
+
395
+ print("\nEditor closed")
396
+ return self.overrides
1227
397
 
1228
398
 
1229
399
  __all__ = ["FigureEditor"]