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
@@ -0,0 +1,364 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Datatable-related Flask route handlers for the figure editor.
5
+ Handles data extraction from figures and plotting from spreadsheet data.
6
+ """
7
+
8
+ from flask import jsonify, request
9
+
10
+ from ._helpers import render_with_overrides, to_json_serializable
11
+
12
+
13
+ def register_datatable_routes(app, editor):
14
+ """Register datatable-related routes with the Flask app."""
15
+ from ._hitmap import generate_hitmap, hitmap_to_base64
16
+
17
+ @app.route("/datatable/data")
18
+ def get_datatable_data():
19
+ """Extract plottable data from the current figure's recorded calls.
20
+
21
+ Returns column-oriented data suitable for spreadsheet display.
22
+ """
23
+ fig = editor.fig
24
+ if not hasattr(fig, "_recorder") or fig._recorder is None:
25
+ return jsonify({"columns": [], "rows": []})
26
+
27
+ record = fig._recorder._figure_record
28
+
29
+ # Collect all plot data
30
+ columns = []
31
+ all_data = {}
32
+ decoration_funcs = {
33
+ "set_xlabel",
34
+ "set_ylabel",
35
+ "set_title",
36
+ "set_xlim",
37
+ "set_ylim",
38
+ "legend",
39
+ "grid",
40
+ "axhline",
41
+ "axvline",
42
+ "text",
43
+ "annotate",
44
+ }
45
+
46
+ for ax_key, ax_record in record.axes.items():
47
+ for call in ax_record.calls:
48
+ if call.function in decoration_funcs:
49
+ continue
50
+
51
+ call_id = call.id or f"{ax_key}_{call.function}_{id(call)}"
52
+
53
+ def extract_data(val):
54
+ """Extract raw data from value, handling dict wrappers."""
55
+ if isinstance(val, dict) and "data" in val:
56
+ return val["data"]
57
+ if isinstance(val, (list, tuple)):
58
+ return list(val)
59
+ return None
60
+
61
+ # Convert args to serializable format
62
+ args = to_json_serializable(call.args)
63
+ kwargs = to_json_serializable(call.kwargs)
64
+
65
+ # Extract x, y data from args
66
+ if args:
67
+ if len(args) >= 2:
68
+ x_data = extract_data(args[0])
69
+ y_data = extract_data(args[1])
70
+ if x_data is not None:
71
+ col_name = f"{call_id}_x"
72
+ all_data[col_name] = x_data
73
+ columns.append(
74
+ {
75
+ "name": col_name,
76
+ "type": "numeric"
77
+ if all(
78
+ isinstance(v, (int, float))
79
+ for v in x_data
80
+ if v is not None
81
+ )
82
+ else "string",
83
+ "index": len(columns),
84
+ }
85
+ )
86
+ if y_data is not None:
87
+ col_name = f"{call_id}_y"
88
+ all_data[col_name] = y_data
89
+ columns.append(
90
+ {
91
+ "name": col_name,
92
+ "type": "numeric"
93
+ if all(
94
+ isinstance(v, (int, float))
95
+ for v in y_data
96
+ if v is not None
97
+ )
98
+ else "string",
99
+ "index": len(columns),
100
+ }
101
+ )
102
+ elif len(args) == 1:
103
+ y_data = extract_data(args[0])
104
+ if y_data is not None:
105
+ col_name = f"{call_id}_y"
106
+ all_data[col_name] = y_data
107
+ columns.append(
108
+ {
109
+ "name": col_name,
110
+ "type": "numeric"
111
+ if all(
112
+ isinstance(v, (int, float))
113
+ for v in y_data
114
+ if v is not None
115
+ )
116
+ else "string",
117
+ "index": len(columns),
118
+ }
119
+ )
120
+
121
+ # Extract from kwargs
122
+ for key in ["x", "y", "height", "width", "c", "s"]:
123
+ if key in kwargs:
124
+ val = extract_data(kwargs[key])
125
+ if val is not None:
126
+ col_name = f"{call_id}_{key}"
127
+ if col_name not in all_data:
128
+ all_data[col_name] = val
129
+ columns.append(
130
+ {
131
+ "name": col_name,
132
+ "type": "numeric"
133
+ if all(
134
+ isinstance(v, (int, float))
135
+ for v in val
136
+ if v is not None
137
+ )
138
+ else "string",
139
+ "index": len(columns),
140
+ }
141
+ )
142
+
143
+ if not all_data:
144
+ return jsonify({"columns": [], "rows": []})
145
+
146
+ # Convert to row-oriented format
147
+ max_len = max(len(v) for v in all_data.values()) if all_data else 0
148
+ rows = []
149
+ col_names = [c["name"] for c in columns]
150
+ for i in range(max_len):
151
+ row = []
152
+ for name in col_names:
153
+ data = all_data.get(name, [])
154
+ if i < len(data):
155
+ row.append(data[i])
156
+ else:
157
+ row.append(None)
158
+ rows.append(row)
159
+
160
+ return jsonify({"columns": columns, "rows": rows})
161
+
162
+ @app.route("/datatable/plot", methods=["POST"])
163
+ def plot_from_datatable():
164
+ """Create a plot from datatable column selections.
165
+
166
+ Expected request body:
167
+ {
168
+ "data": {"col1": [1,2,3], "col2": [4,5,6]},
169
+ "columns": ["col1", "col2"],
170
+ "plot_type": "line", # or "scatter", "bar", "histogram"
171
+ "target_axis": null # null=new figure, 0+=existing axis index
172
+ }
173
+ """
174
+
175
+ data = request.get_json() or {}
176
+ plot_data = data.get("data", {})
177
+ columns = data.get("columns", [])
178
+ plot_type = data.get("plot_type", "line")
179
+ target_axis = data.get("target_axis") # None = new figure
180
+
181
+ if not columns:
182
+ return jsonify({"error": "Please select columns to plot"}), 400
183
+
184
+ if not plot_data:
185
+ return jsonify(
186
+ {"error": "No data available. Drop CSV/TSV data first."}
187
+ ), 400
188
+
189
+ # Check if all columns have empty data
190
+ has_data = any(len(plot_data.get(col, [])) > 0 for col in columns)
191
+ if not has_data:
192
+ return jsonify({"error": "Selected columns have no data"}), 400
193
+
194
+ try:
195
+ mpl_fig = editor.fig.fig if hasattr(editor.fig, "fig") else editor.fig
196
+ axes = mpl_fig.get_axes()
197
+
198
+ # Determine target axis
199
+ if target_axis is not None and target_axis < len(axes):
200
+ # Plot to existing panel
201
+ ax = axes[target_axis]
202
+ else:
203
+ # Add new panel to existing figure
204
+ n_axes = len(axes)
205
+ if n_axes == 0:
206
+ ax = mpl_fig.add_subplot(111)
207
+ else:
208
+ # Expand figure width to accommodate new panel
209
+ current_width, current_height = mpl_fig.get_size_inches()
210
+ # Each panel gets ~60mm width, add space for new panel
211
+ panel_width_inches = 60 / 25.4 # 60mm in inches
212
+ new_width = current_width + panel_width_inches
213
+ mpl_fig.set_size_inches(new_width, current_height)
214
+
215
+ # Recalculate positions for all axes
216
+ n_cols = n_axes + 1
217
+ margin = 0.08
218
+ spacing = 0.05
219
+ panel_w = (1 - 2 * margin - (n_cols - 1) * spacing) / n_cols
220
+
221
+ for i, old_ax in enumerate(axes):
222
+ left = margin + i * (panel_w + spacing)
223
+ old_ax.set_position([left, 0.15, panel_w, 0.75])
224
+
225
+ # Add new panel
226
+ left = margin + n_axes * (panel_w + spacing)
227
+ ax = mpl_fig.add_axes([left, 0.15, panel_w, 0.75])
228
+
229
+ # Dispatch plot using helper
230
+ from ._datatable_plot_handlers import dispatch_plot
231
+
232
+ try:
233
+ dispatch_plot(ax, plot_type, plot_data, columns)
234
+ except ValueError as e:
235
+ return jsonify({"error": str(e)}), 400
236
+
237
+ # Apply current style and render existing figure
238
+ effective_style = editor.get_effective_style()
239
+ recording_fig = editor.fig
240
+
241
+ # Update initial axes positions after adding new panel
242
+ editor._initial_axes_positions = editor._capture_axes_positions()
243
+
244
+ # Render
245
+ base64_img, bboxes, img_size = render_with_overrides(
246
+ recording_fig,
247
+ effective_style,
248
+ editor.dark_mode,
249
+ )
250
+
251
+ # Generate hitmap
252
+ hitmap_img, color_map = generate_hitmap(recording_fig, dpi=150)
253
+ editor._color_map = color_map
254
+ editor._hitmap_base64 = hitmap_to_base64(hitmap_img)
255
+ editor._hitmap_generated = True
256
+
257
+ return jsonify(
258
+ {
259
+ "success": True,
260
+ "image": base64_img,
261
+ "bboxes": bboxes,
262
+ "img_size": {"width": img_size[0], "height": img_size[1]},
263
+ }
264
+ )
265
+
266
+ except Exception as e:
267
+ import traceback
268
+
269
+ traceback.print_exc()
270
+ # Provide user-friendly error message
271
+ error_str = str(e)
272
+ if "Renderer" in error_str or "backend" in error_str:
273
+ error_msg = (
274
+ "Failed to render plot. Please check your data and try again."
275
+ )
276
+ elif "empty" in error_str.lower() or "no data" in error_str.lower():
277
+ error_msg = "No data to plot. Please select columns with numeric data."
278
+ else:
279
+ error_msg = f"Plot error: {type(e).__name__}"
280
+ return jsonify({"error": error_msg}), 500
281
+
282
+ @app.route("/datatable/import", methods=["POST"])
283
+ def import_datatable():
284
+ """Import data from uploaded file content.
285
+
286
+ Expected request body:
287
+ {
288
+ "content": "csv or json content as string",
289
+ "format": "csv" | "json" | "tsv"
290
+ }
291
+ """
292
+ import csv
293
+ import io
294
+ import json
295
+
296
+ data = request.get_json() or {}
297
+ content = data.get("content", "")
298
+ fmt = data.get("format", "csv").lower()
299
+
300
+ try:
301
+ if fmt == "json":
302
+ parsed = json.loads(content)
303
+ if isinstance(parsed, list):
304
+ # Array of objects
305
+ if not parsed:
306
+ return jsonify({"columns": [], "rows": []})
307
+ headers = list(parsed[0].keys())
308
+ rows = [[obj.get(h) for h in headers] for obj in parsed]
309
+ elif isinstance(parsed, dict):
310
+ # Object with column arrays
311
+ headers = list(parsed.keys())
312
+ max_len = max(
313
+ len(v) if isinstance(v, list) else 1 for v in parsed.values()
314
+ )
315
+ rows = []
316
+ for i in range(max_len):
317
+ row = []
318
+ for h in headers:
319
+ v = parsed[h]
320
+ if isinstance(v, list):
321
+ row.append(v[i] if i < len(v) else None)
322
+ else:
323
+ row.append(v if i == 0 else None)
324
+ rows.append(row)
325
+ else:
326
+ return jsonify({"error": "Invalid JSON structure"}), 400
327
+ else:
328
+ # CSV or TSV
329
+ delimiter = "\t" if fmt == "tsv" else ","
330
+ reader = csv.reader(io.StringIO(content), delimiter=delimiter)
331
+ lines = list(reader)
332
+ if not lines:
333
+ return jsonify({"columns": [], "rows": []})
334
+ headers = lines[0]
335
+ rows = []
336
+ for line in lines[1:]:
337
+ row = []
338
+ for i, val in enumerate(line):
339
+ try:
340
+ row.append(float(val))
341
+ except ValueError:
342
+ row.append(val)
343
+ rows.append(row)
344
+
345
+ # Determine column types
346
+ columns = []
347
+ for i, name in enumerate(headers):
348
+ values = [row[i] for row in rows if i < len(row) and row[i] is not None]
349
+ is_numeric = all(isinstance(v, (int, float)) for v in values)
350
+ columns.append(
351
+ {
352
+ "name": name,
353
+ "type": "numeric" if is_numeric else "string",
354
+ "index": i,
355
+ }
356
+ )
357
+
358
+ return jsonify({"columns": columns, "rows": rows})
359
+
360
+ except Exception as e:
361
+ return jsonify({"error": str(e)}), 400
362
+
363
+
364
+ __all__ = ["register_datatable_routes"]
@@ -0,0 +1,335 @@
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
+
82
+ Uses IDENTICAL pipeline as all other routes:
83
+ 1. Store override via set_call_override()
84
+ 2. Call render_with_overrides(editor.fig) - same as initial render
85
+
86
+ The actual property application happens in apply_overrides() via
87
+ apply_call_overrides() - SINGLE SOURCE OF TRUTH.
88
+ """
89
+ data = request.get_json() or {}
90
+ call_id = data.get("call_id")
91
+ param = data.get("param")
92
+ value = data.get("value")
93
+
94
+ if not call_id or not param:
95
+ return jsonify({"error": "Missing call_id or param"}), 400
96
+
97
+ # Find the call and store override
98
+ updated = False
99
+ if hasattr(editor.fig, "record"):
100
+ for ax_key, ax_record in editor.fig.record.axes.items():
101
+ for call in ax_record.calls:
102
+ if call.id == call_id:
103
+ # Store override - will be applied via apply_overrides()
104
+ editor.style_overrides.set_call_override(call_id, param, value)
105
+
106
+ # Also update record kwargs for persistence
107
+ if value is None or value == "" or value == "null":
108
+ call.kwargs.pop(param, None)
109
+ else:
110
+ call.kwargs[param] = value
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
+ # Auto-save recipe if we have a recipe path
121
+ if editor.recipe_path and hasattr(editor.fig, "save_recipe"):
122
+ try:
123
+ editor.fig.save_recipe(editor.recipe_path)
124
+ except Exception as save_err:
125
+ print(f"[Auto-save] Warning: Could not save recipe: {save_err}")
126
+
127
+ try:
128
+ # IDENTICAL to all other routes - single source of truth
129
+ base64_img, bboxes, img_size = render_with_overrides(
130
+ editor.fig,
131
+ editor.get_effective_style(),
132
+ editor.dark_mode,
133
+ )
134
+
135
+ # Regenerate hitmap
136
+ hitmap_img, color_map = generate_hitmap(editor.fig, dpi=150)
137
+ editor._color_map = color_map
138
+ editor._hitmap_base64 = hitmap_to_base64(hitmap_img)
139
+ editor._hitmap_generated = True
140
+
141
+ except Exception as e:
142
+ import traceback
143
+
144
+ traceback.print_exc()
145
+ return jsonify({"error": f"Re-render failed: {str(e)}"}), 500
146
+
147
+ # Get updated call data to sync frontend
148
+ updated_call_data = None
149
+ if hasattr(editor.fig, "record"):
150
+ for ax_key, ax_record in editor.fig.record.axes.items():
151
+ for call in ax_record.calls:
152
+ if call.id == call_id:
153
+ updated_call_data = {
154
+ "kwargs": to_json_serializable(call.kwargs),
155
+ }
156
+ break
157
+ if updated_call_data:
158
+ break
159
+
160
+ return jsonify(
161
+ {
162
+ "success": True,
163
+ "image": base64_img,
164
+ "bboxes": bboxes,
165
+ "img_size": {"width": img_size[0], "height": img_size[1]},
166
+ "call_id": call_id,
167
+ "param": param,
168
+ "value": value,
169
+ "has_call_overrides": editor.style_overrides.has_call_overrides(),
170
+ "updated_call": updated_call_data,
171
+ }
172
+ )
173
+
174
+ @app.route("/download/csv")
175
+ def download_csv():
176
+ """Download plotted data as CSV."""
177
+ import csv
178
+ import io
179
+
180
+ # Get the recorder from the figure
181
+ fig = editor.fig
182
+ if not hasattr(fig, "_recorder") or fig._recorder is None:
183
+ return jsonify({"error": "No recorded data available"}), 400
184
+
185
+ record = fig._recorder._figure_record
186
+
187
+ # Collect all plot data
188
+ all_data = {}
189
+ decoration_funcs = {
190
+ "set_xlabel",
191
+ "set_ylabel",
192
+ "set_title",
193
+ "set_xlim",
194
+ "set_ylim",
195
+ "legend",
196
+ "grid",
197
+ "axhline",
198
+ "axvline",
199
+ "text",
200
+ "annotate",
201
+ }
202
+
203
+ for ax_key, ax_record in record.axes.items():
204
+ for call in ax_record.calls:
205
+ if call.function in decoration_funcs:
206
+ continue
207
+
208
+ call_id = call.id or f"{ax_key}_{call.function}_{id(call)}"
209
+ call_data = {}
210
+
211
+ def extract_data(val):
212
+ """Extract raw data from value, handling dict wrappers."""
213
+ if isinstance(val, dict) and "data" in val:
214
+ return val["data"]
215
+ if isinstance(val, list):
216
+ return val
217
+ return None
218
+
219
+ # Convert args to serializable format
220
+ args = to_json_serializable(call.args)
221
+
222
+ # Extract x, y data from args
223
+ if args:
224
+ if len(args) >= 2:
225
+ x_data = extract_data(args[0])
226
+ y_data = extract_data(args[1])
227
+ if x_data:
228
+ call_data["x"] = x_data
229
+ if y_data:
230
+ call_data["y"] = y_data
231
+ elif len(args) == 1:
232
+ data = extract_data(args[0])
233
+ if data:
234
+ call_data["y"] = data
235
+ call_data["x"] = list(range(len(call_data["y"])))
236
+
237
+ # Extract from kwargs
238
+ kwargs = to_json_serializable(call.kwargs)
239
+ for key in ["x", "y", "height", "width", "c", "s"]:
240
+ if key in kwargs:
241
+ val = extract_data(kwargs[key])
242
+ if val:
243
+ call_data[key] = val
244
+
245
+ if call_data:
246
+ all_data[call_id] = call_data
247
+
248
+ if not all_data:
249
+ return jsonify({"error": "No plot data found"}), 400
250
+
251
+ # Create CSV content
252
+ output = io.StringIO()
253
+
254
+ # Find max length for padding
255
+ max_len = max(max(len(v) for v in data.values()) for data in all_data.values())
256
+
257
+ # Write header
258
+ headers = []
259
+ for call_id, data in all_data.items():
260
+ for key in sorted(data.keys()):
261
+ headers.append(f"{call_id}_{key}")
262
+
263
+ writer = csv.writer(output)
264
+ writer.writerow(headers)
265
+
266
+ # Write data rows
267
+ for i in range(max_len):
268
+ row = []
269
+ for call_id, data in all_data.items():
270
+ for key in sorted(data.keys()):
271
+ values = data[key]
272
+ if i < len(values):
273
+ row.append(values[i])
274
+ else:
275
+ row.append("")
276
+ writer.writerow(row)
277
+
278
+ # Return CSV file
279
+ csv_content = output.getvalue().encode("utf-8")
280
+ filename = "figure_data.csv"
281
+ if editor.recipe_path:
282
+ filename = f"{editor.recipe_path.stem}_data.csv"
283
+
284
+ return send_file(
285
+ io.BytesIO(csv_content),
286
+ mimetype="text/csv",
287
+ as_attachment=True,
288
+ download_name=filename,
289
+ )
290
+
291
+ @app.route("/download/<fmt>")
292
+ def download(fmt: str):
293
+ """Download figure in specified format."""
294
+ import io
295
+
296
+ fmt = fmt.lower()
297
+ if fmt not in ("png", "svg", "pdf"):
298
+ return jsonify({"error": f"Unsupported format: {fmt}"}), 400
299
+
300
+ effective_style = editor.get_effective_style()
301
+ content = render_download(
302
+ editor.fig,
303
+ fmt=fmt,
304
+ dpi=300,
305
+ overrides=effective_style if effective_style else None,
306
+ dark_mode=False,
307
+ )
308
+
309
+ mimetype = {
310
+ "png": "image/png",
311
+ "svg": "image/svg+xml",
312
+ "pdf": "application/pdf",
313
+ }[fmt]
314
+
315
+ filename = f"figure.{fmt}"
316
+ if editor.recipe_path:
317
+ filename = f"{editor.recipe_path.stem}.{fmt}"
318
+
319
+ return send_file(
320
+ io.BytesIO(content),
321
+ mimetype=mimetype,
322
+ as_attachment=True,
323
+ download_name=filename,
324
+ )
325
+
326
+ @app.route("/shutdown", methods=["POST"])
327
+ def shutdown():
328
+ """Shutdown the server."""
329
+ func = request.environ.get("werkzeug.server.shutdown")
330
+ if func:
331
+ func()
332
+ return jsonify({"success": True})
333
+
334
+
335
+ __all__ = ["register_element_routes"]