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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. figrecipe/__init__.py +220 -819
  2. figrecipe/_api/__init__.py +48 -0
  3. figrecipe/_api/_extract.py +108 -0
  4. figrecipe/_api/_notebook.py +61 -0
  5. figrecipe/_api/_panel.py +46 -0
  6. figrecipe/_api/_save.py +191 -0
  7. figrecipe/_api/_seaborn_proxy.py +34 -0
  8. figrecipe/_api/_style_manager.py +153 -0
  9. figrecipe/_api/_subplots.py +333 -0
  10. figrecipe/_api/_validate.py +82 -0
  11. figrecipe/_dev/__init__.py +29 -0
  12. figrecipe/_dev/_plotters.py +76 -0
  13. figrecipe/_dev/_run_demos.py +56 -0
  14. figrecipe/_dev/demo_plotters/__init__.py +64 -0
  15. figrecipe/_dev/demo_plotters/_categories.py +81 -0
  16. figrecipe/_dev/demo_plotters/_figure_creators.py +119 -0
  17. figrecipe/_dev/demo_plotters/_helpers.py +31 -0
  18. figrecipe/_dev/demo_plotters/_registry.py +50 -0
  19. figrecipe/_dev/demo_plotters/bar_categorical/__init__.py +4 -0
  20. figrecipe/_dev/demo_plotters/bar_categorical/plot_bar.py +25 -0
  21. figrecipe/_dev/demo_plotters/bar_categorical/plot_barh.py +25 -0
  22. figrecipe/_dev/demo_plotters/contour_surface/__init__.py +4 -0
  23. figrecipe/_dev/demo_plotters/contour_surface/plot_contour.py +30 -0
  24. figrecipe/_dev/demo_plotters/contour_surface/plot_contourf.py +29 -0
  25. figrecipe/_dev/demo_plotters/contour_surface/plot_tricontour.py +28 -0
  26. figrecipe/_dev/demo_plotters/contour_surface/plot_tricontourf.py +28 -0
  27. figrecipe/_dev/demo_plotters/contour_surface/plot_tripcolor.py +29 -0
  28. figrecipe/_dev/demo_plotters/contour_surface/plot_triplot.py +25 -0
  29. figrecipe/_dev/demo_plotters/distribution/__init__.py +4 -0
  30. figrecipe/_dev/demo_plotters/distribution/plot_boxplot.py +24 -0
  31. figrecipe/_dev/demo_plotters/distribution/plot_ecdf.py +24 -0
  32. figrecipe/_dev/demo_plotters/distribution/plot_hist.py +24 -0
  33. figrecipe/_dev/demo_plotters/distribution/plot_hist2d.py +25 -0
  34. figrecipe/_dev/demo_plotters/distribution/plot_violinplot.py +25 -0
  35. figrecipe/_dev/demo_plotters/image_matrix/__init__.py +4 -0
  36. figrecipe/_dev/demo_plotters/image_matrix/plot_hexbin.py +25 -0
  37. figrecipe/_dev/demo_plotters/image_matrix/plot_imshow.py +23 -0
  38. figrecipe/_dev/demo_plotters/image_matrix/plot_matshow.py +23 -0
  39. figrecipe/_dev/demo_plotters/image_matrix/plot_pcolor.py +29 -0
  40. figrecipe/_dev/demo_plotters/image_matrix/plot_pcolormesh.py +29 -0
  41. figrecipe/_dev/demo_plotters/image_matrix/plot_spy.py +29 -0
  42. figrecipe/_dev/demo_plotters/line_curve/__init__.py +4 -0
  43. figrecipe/_dev/demo_plotters/line_curve/plot_errorbar.py +28 -0
  44. figrecipe/_dev/demo_plotters/line_curve/plot_fill.py +29 -0
  45. figrecipe/_dev/demo_plotters/line_curve/plot_fill_between.py +30 -0
  46. figrecipe/_dev/demo_plotters/line_curve/plot_fill_betweenx.py +28 -0
  47. figrecipe/_dev/demo_plotters/line_curve/plot_plot.py +28 -0
  48. figrecipe/_dev/demo_plotters/line_curve/plot_stackplot.py +29 -0
  49. figrecipe/_dev/demo_plotters/line_curve/plot_stairs.py +27 -0
  50. figrecipe/_dev/demo_plotters/line_curve/plot_step.py +27 -0
  51. figrecipe/_dev/demo_plotters/scatter_points/__init__.py +4 -0
  52. figrecipe/_dev/demo_plotters/scatter_points/plot_scatter.py +24 -0
  53. figrecipe/_dev/demo_plotters/special/__init__.py +4 -0
  54. figrecipe/_dev/demo_plotters/special/plot_eventplot.py +25 -0
  55. figrecipe/_dev/demo_plotters/special/plot_loglog.py +27 -0
  56. figrecipe/_dev/demo_plotters/special/plot_pie.py +27 -0
  57. figrecipe/_dev/demo_plotters/special/plot_semilogx.py +27 -0
  58. figrecipe/_dev/demo_plotters/special/plot_semilogy.py +27 -0
  59. figrecipe/_dev/demo_plotters/special/plot_stem.py +27 -0
  60. figrecipe/_dev/demo_plotters/spectral_signal/__init__.py +4 -0
  61. figrecipe/_dev/demo_plotters/spectral_signal/plot_acorr.py +24 -0
  62. figrecipe/_dev/demo_plotters/spectral_signal/plot_angle_spectrum.py +28 -0
  63. figrecipe/_dev/demo_plotters/spectral_signal/plot_cohere.py +29 -0
  64. figrecipe/_dev/demo_plotters/spectral_signal/plot_csd.py +29 -0
  65. figrecipe/_dev/demo_plotters/spectral_signal/plot_magnitude_spectrum.py +28 -0
  66. figrecipe/_dev/demo_plotters/spectral_signal/plot_phase_spectrum.py +28 -0
  67. figrecipe/_dev/demo_plotters/spectral_signal/plot_psd.py +29 -0
  68. figrecipe/_dev/demo_plotters/spectral_signal/plot_specgram.py +30 -0
  69. figrecipe/_dev/demo_plotters/spectral_signal/plot_xcorr.py +25 -0
  70. figrecipe/_dev/demo_plotters/vector_flow/__init__.py +4 -0
  71. figrecipe/_dev/demo_plotters/vector_flow/plot_barbs.py +30 -0
  72. figrecipe/_dev/demo_plotters/vector_flow/plot_quiver.py +30 -0
  73. figrecipe/_dev/demo_plotters/vector_flow/plot_streamplot.py +30 -0
  74. figrecipe/_editor/__init__.py +278 -0
  75. figrecipe/_editor/_bbox/__init__.py +43 -0
  76. figrecipe/_editor/_bbox/_collections.py +177 -0
  77. figrecipe/_editor/_bbox/_elements.py +159 -0
  78. figrecipe/_editor/_bbox/_extract.py +256 -0
  79. figrecipe/_editor/_bbox/_extract_axes.py +370 -0
  80. figrecipe/_editor/_bbox/_extract_text.py +342 -0
  81. figrecipe/_editor/_bbox/_lines.py +173 -0
  82. figrecipe/_editor/_bbox/_transforms.py +146 -0
  83. figrecipe/_editor/_flask_app.py +258 -0
  84. figrecipe/_editor/_helpers.py +242 -0
  85. figrecipe/_editor/_hitmap/__init__.py +76 -0
  86. figrecipe/_editor/_hitmap/_artists/__init__.py +21 -0
  87. figrecipe/_editor/_hitmap/_artists/_collections.py +345 -0
  88. figrecipe/_editor/_hitmap/_artists/_images.py +68 -0
  89. figrecipe/_editor/_hitmap/_artists/_lines.py +107 -0
  90. figrecipe/_editor/_hitmap/_artists/_patches.py +163 -0
  91. figrecipe/_editor/_hitmap/_artists/_text.py +190 -0
  92. figrecipe/_editor/_hitmap/_colors.py +181 -0
  93. figrecipe/_editor/_hitmap/_detect.py +137 -0
  94. figrecipe/_editor/_hitmap/_restore.py +154 -0
  95. figrecipe/_editor/_hitmap_main.py +182 -0
  96. figrecipe/_editor/_overrides.py +318 -0
  97. figrecipe/_editor/_preferences.py +135 -0
  98. figrecipe/_editor/_render_overrides.py +480 -0
  99. figrecipe/_editor/_renderer.py +199 -0
  100. figrecipe/_editor/_routes_axis.py +453 -0
  101. figrecipe/_editor/_routes_core.py +284 -0
  102. figrecipe/_editor/_routes_element.py +317 -0
  103. figrecipe/_editor/_routes_style.py +223 -0
  104. figrecipe/_editor/_templates/__init__.py +152 -0
  105. figrecipe/_editor/_templates/_html.py +502 -0
  106. figrecipe/_editor/_templates/_scripts/__init__.py +120 -0
  107. figrecipe/_editor/_templates/_scripts/_api.py +228 -0
  108. figrecipe/_editor/_templates/_scripts/_colors.py +485 -0
  109. figrecipe/_editor/_templates/_scripts/_core.py +436 -0
  110. figrecipe/_editor/_templates/_scripts/_debug_snapshot.py +186 -0
  111. figrecipe/_editor/_templates/_scripts/_element_editor.py +310 -0
  112. figrecipe/_editor/_templates/_scripts/_files.py +195 -0
  113. figrecipe/_editor/_templates/_scripts/_hitmap.py +509 -0
  114. figrecipe/_editor/_templates/_scripts/_inspector.py +315 -0
  115. figrecipe/_editor/_templates/_scripts/_labels.py +464 -0
  116. figrecipe/_editor/_templates/_scripts/_legend_drag.py +265 -0
  117. figrecipe/_editor/_templates/_scripts/_modals.py +226 -0
  118. figrecipe/_editor/_templates/_scripts/_overlays.py +292 -0
  119. figrecipe/_editor/_templates/_scripts/_panel_drag.py +334 -0
  120. figrecipe/_editor/_templates/_scripts/_panel_position.py +279 -0
  121. figrecipe/_editor/_templates/_scripts/_selection.py +237 -0
  122. figrecipe/_editor/_templates/_scripts/_tabs.py +89 -0
  123. figrecipe/_editor/_templates/_scripts/_view_mode.py +107 -0
  124. figrecipe/_editor/_templates/_scripts/_zoom.py +179 -0
  125. figrecipe/_editor/_templates/_styles/__init__.py +69 -0
  126. figrecipe/_editor/_templates/_styles/_base.py +64 -0
  127. figrecipe/_editor/_templates/_styles/_buttons.py +206 -0
  128. figrecipe/_editor/_templates/_styles/_color_input.py +123 -0
  129. figrecipe/_editor/_templates/_styles/_controls.py +265 -0
  130. figrecipe/_editor/_templates/_styles/_dynamic_props.py +144 -0
  131. figrecipe/_editor/_templates/_styles/_forms.py +126 -0
  132. figrecipe/_editor/_templates/_styles/_hitmap.py +184 -0
  133. figrecipe/_editor/_templates/_styles/_inspector.py +90 -0
  134. figrecipe/_editor/_templates/_styles/_labels.py +118 -0
  135. figrecipe/_editor/_templates/_styles/_modals.py +98 -0
  136. figrecipe/_editor/_templates/_styles/_overlays.py +130 -0
  137. figrecipe/_editor/_templates/_styles/_preview.py +225 -0
  138. figrecipe/_editor/_templates/_styles/_selection.py +73 -0
  139. figrecipe/_params/_DECORATION_METHODS.py +33 -0
  140. figrecipe/_params/_PLOTTING_METHODS.py +58 -0
  141. figrecipe/_params/__init__.py +9 -0
  142. figrecipe/_recorder.py +92 -110
  143. figrecipe/_recorder_utils.py +124 -0
  144. figrecipe/_reproducer/__init__.py +18 -0
  145. figrecipe/_reproducer/_core.py +498 -0
  146. figrecipe/_reproducer/_custom_plots.py +279 -0
  147. figrecipe/_reproducer/_seaborn.py +100 -0
  148. figrecipe/_reproducer/_violin.py +186 -0
  149. figrecipe/_seaborn.py +14 -9
  150. figrecipe/_serializer.py +2 -2
  151. figrecipe/_signatures/README.md +68 -0
  152. figrecipe/_signatures/__init__.py +12 -2
  153. figrecipe/_signatures/_kwargs.py +273 -0
  154. figrecipe/_signatures/_loader.py +114 -57
  155. figrecipe/_signatures/_parsing.py +147 -0
  156. figrecipe/_utils/__init__.py +6 -4
  157. figrecipe/_utils/_crop.py +10 -4
  158. figrecipe/_utils/_image_diff.py +37 -33
  159. figrecipe/_utils/_numpy_io.py +0 -1
  160. figrecipe/_utils/_units.py +11 -3
  161. figrecipe/_validator.py +12 -3
  162. figrecipe/_wrappers/_axes.py +193 -170
  163. figrecipe/_wrappers/_axes_helpers.py +136 -0
  164. figrecipe/_wrappers/_axes_plots.py +418 -0
  165. figrecipe/_wrappers/_axes_seaborn.py +157 -0
  166. figrecipe/_wrappers/_figure.py +277 -18
  167. figrecipe/_wrappers/_panel_labels.py +127 -0
  168. figrecipe/_wrappers/_plot_helpers.py +143 -0
  169. figrecipe/_wrappers/_violin_helpers.py +180 -0
  170. figrecipe/plt.py +0 -1
  171. figrecipe/pyplot.py +2 -1
  172. figrecipe/styles/__init__.py +12 -11
  173. figrecipe/styles/_dotdict.py +72 -0
  174. figrecipe/styles/_finalize.py +134 -0
  175. figrecipe/styles/_fonts.py +77 -0
  176. figrecipe/styles/_kwargs_converter.py +178 -0
  177. figrecipe/styles/_plot_styles.py +209 -0
  178. figrecipe/styles/_style_applier.py +60 -202
  179. figrecipe/styles/_style_loader.py +73 -121
  180. figrecipe/styles/_themes.py +151 -0
  181. figrecipe/styles/presets/MATPLOTLIB.yaml +95 -0
  182. figrecipe/styles/presets/SCITEX.yaml +181 -0
  183. figrecipe-0.7.4.dist-info/METADATA +429 -0
  184. figrecipe-0.7.4.dist-info/RECORD +188 -0
  185. figrecipe/_reproducer.py +0 -358
  186. figrecipe-0.5.0.dist-info/METADATA +0 -336
  187. figrecipe-0.5.0.dist-info/RECORD +0 -26
  188. {figrecipe-0.5.0.dist-info → figrecipe-0.7.4.dist-info}/WHEEL +0 -0
  189. {figrecipe-0.5.0.dist-info → figrecipe-0.7.4.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,284 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Core Flask route handlers for the figure editor.
5
+ Handles main page, preview, update, hitmap, and file switching routes.
6
+ """
7
+
8
+ from flask import jsonify, render_template_string, request
9
+
10
+ from ._helpers import render_with_overrides
11
+
12
+
13
+ def register_core_routes(app, editor):
14
+ """Register core routes with the Flask app."""
15
+ from ._hitmap import generate_hitmap, hitmap_to_base64
16
+ from ._templates import build_html_template
17
+
18
+ @app.route("/")
19
+ def index():
20
+ """Main editor page."""
21
+ base64_img, bboxes, img_size = render_with_overrides(
22
+ editor.fig,
23
+ editor.get_effective_style(),
24
+ editor.dark_mode,
25
+ )
26
+
27
+ style_name = getattr(editor, "_style_name", "SCITEX")
28
+
29
+ html = build_html_template(
30
+ image_base64=base64_img,
31
+ bboxes=bboxes,
32
+ color_map=editor._color_map,
33
+ style=editor.style,
34
+ overrides=editor.get_effective_style(),
35
+ img_size=img_size,
36
+ style_name=style_name,
37
+ hot_reload=editor.hot_reload,
38
+ dark_mode=editor.dark_mode,
39
+ )
40
+
41
+ return render_template_string(html)
42
+
43
+ @app.route("/preview")
44
+ def preview():
45
+ """Get current preview image."""
46
+ base64_img, bboxes, img_size = render_with_overrides(
47
+ editor.fig,
48
+ editor.get_effective_style(),
49
+ editor.dark_mode,
50
+ )
51
+
52
+ return jsonify(
53
+ {
54
+ "image": base64_img,
55
+ "bboxes": bboxes,
56
+ "img_size": {"width": img_size[0], "height": img_size[1]},
57
+ }
58
+ )
59
+
60
+ @app.route("/ping")
61
+ def ping():
62
+ """Health check endpoint for hot reload detection."""
63
+ return jsonify({"status": "ok"})
64
+
65
+ @app.route("/update", methods=["POST"])
66
+ def update():
67
+ """Update preview with new style overrides."""
68
+ from ._preferences import set_preference
69
+
70
+ data = request.get_json() or {}
71
+
72
+ editor.overrides.update(data.get("overrides", {}))
73
+
74
+ # Update and persist dark mode preference
75
+ new_dark_mode = data.get("dark_mode")
76
+ if new_dark_mode is not None and new_dark_mode != editor.dark_mode:
77
+ editor.dark_mode = new_dark_mode
78
+ set_preference("dark_mode", new_dark_mode)
79
+
80
+ base64_img, bboxes, img_size = render_with_overrides(
81
+ editor.fig,
82
+ editor.get_effective_style(),
83
+ editor.dark_mode,
84
+ )
85
+
86
+ return jsonify(
87
+ {
88
+ "image": base64_img,
89
+ "bboxes": bboxes,
90
+ "img_size": {"width": img_size[0], "height": img_size[1]},
91
+ }
92
+ )
93
+
94
+ @app.route("/hitmap")
95
+ def hitmap():
96
+ """Get hitmap image and color map (lazy generation on first request)."""
97
+ if not editor._hitmap_generated:
98
+ print("Generating hitmap (first request)...")
99
+ hitmap_img, editor._color_map = generate_hitmap(editor.fig)
100
+ editor._hitmap_base64 = hitmap_to_base64(hitmap_img)
101
+ editor._hitmap_generated = True
102
+ print("Hitmap ready.")
103
+
104
+ return jsonify(
105
+ {
106
+ "image": editor._hitmap_base64,
107
+ "color_map": editor._color_map,
108
+ }
109
+ )
110
+
111
+ @app.route("/api/files")
112
+ def list_files():
113
+ """List available recipe files in working directory."""
114
+ from pathlib import Path
115
+
116
+ working_dir = getattr(editor, "working_dir", Path.cwd())
117
+ files = []
118
+
119
+ # Find all YAML recipe files
120
+ for pattern in ["*.yaml", "*.yml"]:
121
+ for f in working_dir.glob(pattern):
122
+ # Skip hidden files and overrides files
123
+ if f.name.startswith(".") or f.name.endswith(".overrides.yaml"):
124
+ continue
125
+
126
+ # Check for associated PNG
127
+ png_path = f.with_suffix(".png")
128
+ has_png = png_path.exists()
129
+
130
+ files.append(
131
+ {
132
+ "path": str(f.relative_to(working_dir)),
133
+ "name": f.stem,
134
+ "has_image": has_png,
135
+ "is_current": (
136
+ editor.recipe_path
137
+ and f.resolve() == editor.recipe_path.resolve()
138
+ ),
139
+ }
140
+ )
141
+
142
+ # Sort by name
143
+ files.sort(key=lambda x: x["name"].lower())
144
+
145
+ return jsonify(
146
+ {
147
+ "files": files,
148
+ "working_dir": str(working_dir),
149
+ "current_file": (
150
+ str(editor.recipe_path.name) if editor.recipe_path else None
151
+ ),
152
+ }
153
+ )
154
+
155
+ @app.route("/api/switch", methods=["POST"])
156
+ def switch_file():
157
+ """Switch to a different recipe file."""
158
+ from pathlib import Path
159
+
160
+ from .._reproducer import reproduce
161
+
162
+ data = request.get_json() or {}
163
+ file_path = data.get("path")
164
+
165
+ if not file_path:
166
+ return jsonify({"error": "No file path provided"}), 400
167
+
168
+ working_dir = getattr(editor, "working_dir", Path.cwd())
169
+ full_path = working_dir / file_path
170
+
171
+ if not full_path.exists():
172
+ return jsonify({"error": f"File not found: {file_path}"}), 404
173
+
174
+ try:
175
+ # Reproduce the figure from the new recipe
176
+ fig, axes = reproduce(full_path)
177
+
178
+ # Wrap in RecordingFigure if needed
179
+ from .._wrappers._figure import RecordingFigure
180
+
181
+ if not isinstance(fig, RecordingFigure):
182
+ from .._recorder import FigureRecord, Recorder
183
+
184
+ wrapped_fig = RecordingFigure.__new__(RecordingFigure)
185
+ wrapped_fig._fig = fig
186
+ wrapped_fig._axes = [[ax] for ax in fig.axes]
187
+ wrapped_fig._recorder = Recorder()
188
+ wrapped_fig._recorder._figure_record = FigureRecord(
189
+ figsize=tuple(fig.get_size_inches()),
190
+ dpi=int(fig.dpi),
191
+ )
192
+ fig = wrapped_fig
193
+
194
+ # Update editor state
195
+ editor.fig = fig
196
+ editor.recipe_path = full_path
197
+ editor._hitmap_generated = False
198
+ editor._color_map = {}
199
+
200
+ # Re-init style overrides
201
+ editor._init_style_overrides(None)
202
+
203
+ # Regenerate hitmap
204
+ hitmap_img, editor._color_map = generate_hitmap(editor.fig)
205
+ editor._hitmap_base64 = hitmap_to_base64(hitmap_img)
206
+ editor._hitmap_generated = True
207
+
208
+ # Render new preview
209
+ base64_img, bboxes, img_size = render_with_overrides(
210
+ editor.fig,
211
+ editor.get_effective_style(),
212
+ editor.dark_mode,
213
+ )
214
+
215
+ return jsonify(
216
+ {
217
+ "success": True,
218
+ "image": base64_img,
219
+ "bboxes": bboxes,
220
+ "color_map": editor._color_map,
221
+ "img_size": {"width": img_size[0], "height": img_size[1]},
222
+ "file": file_path,
223
+ }
224
+ )
225
+
226
+ except Exception as e:
227
+ return jsonify({"error": str(e)}), 500
228
+
229
+ @app.route("/api/new", methods=["POST"])
230
+ def new_figure():
231
+ """Create a new blank figure."""
232
+ from .. import subplots
233
+
234
+ try:
235
+ # Create new blank figure
236
+ fig, ax = subplots()
237
+ ax.set_title("New Figure")
238
+ ax.text(
239
+ 0.5,
240
+ 0.5,
241
+ "Add plots using fr.edit(fig)",
242
+ ha="center",
243
+ va="center",
244
+ transform=ax.transAxes,
245
+ fontsize=12,
246
+ color="gray",
247
+ )
248
+
249
+ # Update editor state
250
+ editor.fig = fig
251
+ editor.recipe_path = None
252
+ editor._hitmap_generated = False
253
+ editor._color_map = {}
254
+
255
+ # Re-init style overrides
256
+ editor._init_style_overrides(None)
257
+
258
+ # Regenerate hitmap
259
+ hitmap_img, editor._color_map = generate_hitmap(editor.fig)
260
+ editor._hitmap_base64 = hitmap_to_base64(hitmap_img)
261
+ editor._hitmap_generated = True
262
+
263
+ # Render new preview
264
+ base64_img, bboxes, img_size = render_with_overrides(
265
+ editor.fig,
266
+ editor.get_effective_style(),
267
+ editor.dark_mode,
268
+ )
269
+
270
+ return jsonify(
271
+ {
272
+ "success": True,
273
+ "image": base64_img,
274
+ "bboxes": bboxes,
275
+ "color_map": editor._color_map,
276
+ "img_size": {"width": img_size[0], "height": img_size[1]},
277
+ }
278
+ )
279
+
280
+ except Exception as e:
281
+ return jsonify({"error": str(e)}), 500
282
+
283
+
284
+ __all__ = ["register_core_routes"]
@@ -0,0 +1,317 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Element-related Flask route handlers for the figure editor.
5
+ Handles calls, download, and shutdown routes.
6
+ """
7
+
8
+ from flask import jsonify, request, send_file
9
+
10
+ from ._helpers import render_with_overrides, to_json_serializable
11
+
12
+
13
+ def register_element_routes(app, editor):
14
+ """Register element-related routes with the Flask app."""
15
+ from ._hitmap import generate_hitmap, hitmap_to_base64
16
+ from ._renderer import render_download
17
+
18
+ @app.route("/calls")
19
+ def get_calls():
20
+ """Get all recorded calls with their signatures."""
21
+ from .._signatures import get_signature
22
+
23
+ calls_data = {}
24
+ if hasattr(editor.fig, "record"):
25
+ for ax_key, ax_record in editor.fig.record.axes.items():
26
+ for call in ax_record.calls:
27
+ call_id = call.id
28
+ func_name = call.function
29
+ sig = get_signature(func_name)
30
+
31
+ calls_data[call_id] = {
32
+ "function": func_name,
33
+ "ax_key": ax_key,
34
+ "args": to_json_serializable(call.args),
35
+ "kwargs": to_json_serializable(call.kwargs),
36
+ "signature": {
37
+ "args": sig.get("args", []),
38
+ "kwargs": {
39
+ k: v
40
+ for k, v in sig.get("kwargs", {}).items()
41
+ if k != "**kwargs"
42
+ },
43
+ },
44
+ }
45
+
46
+ return jsonify(calls_data)
47
+
48
+ @app.route("/call/<call_id>")
49
+ def get_call(call_id):
50
+ """Get recorded call data by call_id."""
51
+ from .._signatures import get_signature
52
+
53
+ if hasattr(editor.fig, "record"):
54
+ for ax_key, ax_record in editor.fig.record.axes.items():
55
+ for call in ax_record.calls:
56
+ if call.id == call_id:
57
+ sig = get_signature(call.function)
58
+ return jsonify(
59
+ {
60
+ "call_id": call_id,
61
+ "function": call.function,
62
+ "ax_key": ax_key,
63
+ "args": call.args,
64
+ "kwargs": call.kwargs,
65
+ "signature": {
66
+ "args": sig.get("args", []),
67
+ "kwargs": {
68
+ k: v
69
+ for k, v in sig.get("kwargs", {}).items()
70
+ if k != "**kwargs"
71
+ },
72
+ },
73
+ }
74
+ )
75
+
76
+ return jsonify({"error": f"Call {call_id} not found"}), 404
77
+
78
+ @app.route("/update_call", methods=["POST"])
79
+ def update_call():
80
+ """Update a call's kwargs and re-render."""
81
+ from .._reproducer import reproduce_from_record
82
+
83
+ data = request.get_json() or {}
84
+ call_id = data.get("call_id")
85
+ param = data.get("param")
86
+ value = data.get("value")
87
+
88
+ if not call_id or not param:
89
+ return jsonify({"error": "Missing call_id or param"}), 400
90
+
91
+ updated = False
92
+ if hasattr(editor.fig, "record"):
93
+ for ax_key, ax_record in editor.fig.record.axes.items():
94
+ for call in ax_record.calls:
95
+ if call.id == call_id:
96
+ # Debug: log the update
97
+ print(f"[DEBUG] update_call: {call_id}.{param} = {value}")
98
+ print(
99
+ f"[DEBUG] Before: call.kwargs[{param}] = {call.kwargs.get(param)}"
100
+ )
101
+
102
+ editor.style_overrides.set_call_override(call_id, param, value)
103
+
104
+ if value is None or value == "" or value == "null":
105
+ call.kwargs.pop(param, None)
106
+ else:
107
+ call.kwargs[param] = value
108
+
109
+ print(
110
+ f"[DEBUG] After: call.kwargs[{param}] = {call.kwargs.get(param)}"
111
+ )
112
+ updated = True
113
+ break
114
+ if updated:
115
+ break
116
+
117
+ if not updated:
118
+ return jsonify({"error": f"Call {call_id} not found"}), 404
119
+
120
+ try:
121
+ new_fig, _ = reproduce_from_record(editor.fig.record)
122
+
123
+ effective_style = editor.get_effective_style()
124
+ base64_img, bboxes, img_size = render_with_overrides(
125
+ new_fig,
126
+ effective_style if effective_style else None,
127
+ editor.dark_mode,
128
+ )
129
+
130
+ editor.fig = new_fig
131
+
132
+ hitmap_img, color_map = generate_hitmap(new_fig, img_size[0], img_size[1])
133
+ editor._color_map = color_map
134
+ editor._hitmap_base64 = hitmap_to_base64(hitmap_img)
135
+ editor._hitmap_generated = True
136
+
137
+ except Exception as e:
138
+ import traceback
139
+
140
+ traceback.print_exc()
141
+ return jsonify({"error": f"Re-render failed: {str(e)}"}), 500
142
+
143
+ return jsonify(
144
+ {
145
+ "success": True,
146
+ "image": base64_img,
147
+ "bboxes": bboxes,
148
+ "img_size": {"width": img_size[0], "height": img_size[1]},
149
+ "call_id": call_id,
150
+ "param": param,
151
+ "value": value,
152
+ "has_call_overrides": editor.style_overrides.has_call_overrides(),
153
+ }
154
+ )
155
+
156
+ @app.route("/download/csv")
157
+ def download_csv():
158
+ """Download plotted data as CSV."""
159
+ import csv
160
+ import io
161
+
162
+ # Get the recorder from the figure
163
+ fig = editor.fig
164
+ if not hasattr(fig, "_recorder") or fig._recorder is None:
165
+ return jsonify({"error": "No recorded data available"}), 400
166
+
167
+ record = fig._recorder._figure_record
168
+
169
+ # Collect all plot data
170
+ all_data = {}
171
+ decoration_funcs = {
172
+ "set_xlabel",
173
+ "set_ylabel",
174
+ "set_title",
175
+ "set_xlim",
176
+ "set_ylim",
177
+ "legend",
178
+ "grid",
179
+ "axhline",
180
+ "axvline",
181
+ "text",
182
+ "annotate",
183
+ }
184
+
185
+ for ax_key, ax_record in record.axes.items():
186
+ for call in ax_record.calls:
187
+ if call.function in decoration_funcs:
188
+ continue
189
+
190
+ call_id = call.id or f"{ax_key}_{call.function}_{id(call)}"
191
+ call_data = {}
192
+
193
+ def extract_data(val):
194
+ """Extract raw data from value, handling dict wrappers."""
195
+ if isinstance(val, dict) and "data" in val:
196
+ return val["data"]
197
+ if isinstance(val, list):
198
+ return val
199
+ return None
200
+
201
+ # Convert args to serializable format
202
+ args = to_json_serializable(call.args)
203
+
204
+ # Extract x, y data from args
205
+ if args:
206
+ if len(args) >= 2:
207
+ x_data = extract_data(args[0])
208
+ y_data = extract_data(args[1])
209
+ if x_data:
210
+ call_data["x"] = x_data
211
+ if y_data:
212
+ call_data["y"] = y_data
213
+ elif len(args) == 1:
214
+ data = extract_data(args[0])
215
+ if data:
216
+ call_data["y"] = data
217
+ call_data["x"] = list(range(len(call_data["y"])))
218
+
219
+ # Extract from kwargs
220
+ kwargs = to_json_serializable(call.kwargs)
221
+ for key in ["x", "y", "height", "width", "c", "s"]:
222
+ if key in kwargs:
223
+ val = extract_data(kwargs[key])
224
+ if val:
225
+ call_data[key] = val
226
+
227
+ if call_data:
228
+ all_data[call_id] = call_data
229
+
230
+ if not all_data:
231
+ return jsonify({"error": "No plot data found"}), 400
232
+
233
+ # Create CSV content
234
+ output = io.StringIO()
235
+
236
+ # Find max length for padding
237
+ max_len = max(max(len(v) for v in data.values()) for data in all_data.values())
238
+
239
+ # Write header
240
+ headers = []
241
+ for call_id, data in all_data.items():
242
+ for key in sorted(data.keys()):
243
+ headers.append(f"{call_id}_{key}")
244
+
245
+ writer = csv.writer(output)
246
+ writer.writerow(headers)
247
+
248
+ # Write data rows
249
+ for i in range(max_len):
250
+ row = []
251
+ for call_id, data in all_data.items():
252
+ for key in sorted(data.keys()):
253
+ values = data[key]
254
+ if i < len(values):
255
+ row.append(values[i])
256
+ else:
257
+ row.append("")
258
+ writer.writerow(row)
259
+
260
+ # Return CSV file
261
+ csv_content = output.getvalue().encode("utf-8")
262
+ filename = "figure_data.csv"
263
+ if editor.recipe_path:
264
+ filename = f"{editor.recipe_path.stem}_data.csv"
265
+
266
+ return send_file(
267
+ io.BytesIO(csv_content),
268
+ mimetype="text/csv",
269
+ as_attachment=True,
270
+ download_name=filename,
271
+ )
272
+
273
+ @app.route("/download/<fmt>")
274
+ def download(fmt: str):
275
+ """Download figure in specified format."""
276
+ import io
277
+
278
+ fmt = fmt.lower()
279
+ if fmt not in ("png", "svg", "pdf"):
280
+ return jsonify({"error": f"Unsupported format: {fmt}"}), 400
281
+
282
+ effective_style = editor.get_effective_style()
283
+ content = render_download(
284
+ editor.fig,
285
+ fmt=fmt,
286
+ dpi=300,
287
+ overrides=effective_style if effective_style else None,
288
+ dark_mode=False,
289
+ )
290
+
291
+ mimetype = {
292
+ "png": "image/png",
293
+ "svg": "image/svg+xml",
294
+ "pdf": "application/pdf",
295
+ }[fmt]
296
+
297
+ filename = f"figure.{fmt}"
298
+ if editor.recipe_path:
299
+ filename = f"{editor.recipe_path.stem}.{fmt}"
300
+
301
+ return send_file(
302
+ io.BytesIO(content),
303
+ mimetype=mimetype,
304
+ as_attachment=True,
305
+ download_name=filename,
306
+ )
307
+
308
+ @app.route("/shutdown", methods=["POST"])
309
+ def shutdown():
310
+ """Shutdown the server."""
311
+ func = request.environ.get("werkzeug.server.shutdown")
312
+ if func:
313
+ func()
314
+ return jsonify({"success": True})
315
+
316
+
317
+ __all__ = ["register_element_routes"]