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.
- figrecipe/__init__.py +161 -1030
- figrecipe/__main__.py +12 -0
- figrecipe/_api/__init__.py +48 -0
- figrecipe/_api/_extract.py +108 -0
- figrecipe/_api/_notebook.py +61 -0
- figrecipe/_api/_panel.py +113 -0
- figrecipe/_api/_save.py +287 -0
- figrecipe/_api/_seaborn_proxy.py +34 -0
- figrecipe/_api/_style_manager.py +153 -0
- figrecipe/_api/_subplots.py +333 -0
- figrecipe/_api/_validate.py +82 -0
- figrecipe/_cli/__init__.py +7 -0
- figrecipe/_cli/_compose.py +87 -0
- figrecipe/_cli/_convert.py +117 -0
- figrecipe/_cli/_crop.py +82 -0
- figrecipe/_cli/_edit.py +70 -0
- figrecipe/_cli/_extract.py +128 -0
- figrecipe/_cli/_fonts.py +47 -0
- figrecipe/_cli/_info.py +67 -0
- figrecipe/_cli/_main.py +58 -0
- figrecipe/_cli/_reproduce.py +79 -0
- figrecipe/_cli/_style.py +77 -0
- figrecipe/_cli/_validate.py +66 -0
- figrecipe/_cli/_version.py +50 -0
- figrecipe/_composition/__init__.py +32 -0
- figrecipe/_composition/_alignment.py +452 -0
- figrecipe/_composition/_compose.py +179 -0
- figrecipe/_composition/_import_axes.py +127 -0
- figrecipe/_composition/_visibility.py +125 -0
- figrecipe/_dev/__init__.py +4 -93
- figrecipe/_dev/_plotters.py +76 -0
- figrecipe/_dev/_run_demos.py +56 -0
- figrecipe/_dev/browser/__init__.py +69 -0
- figrecipe/_dev/browser/_audio.py +240 -0
- figrecipe/_dev/browser/_caption.py +356 -0
- figrecipe/_dev/browser/_click_effect.py +146 -0
- figrecipe/_dev/browser/_cursor.py +196 -0
- figrecipe/_dev/browser/_highlight.py +105 -0
- figrecipe/_dev/browser/_narration.py +237 -0
- figrecipe/_dev/browser/_recorder.py +446 -0
- figrecipe/_dev/browser/_utils.py +178 -0
- figrecipe/_dev/browser/_video_trim/__init__.py +152 -0
- figrecipe/_dev/browser/_video_trim/_detection.py +223 -0
- figrecipe/_dev/browser/_video_trim/_markers.py +140 -0
- figrecipe/_dev/demo_plotters/__init__.py +35 -166
- figrecipe/_dev/demo_plotters/_categories.py +81 -0
- figrecipe/_dev/demo_plotters/_figure_creators.py +119 -0
- figrecipe/_dev/demo_plotters/_helpers.py +31 -0
- figrecipe/_dev/demo_plotters/_registry.py +50 -0
- figrecipe/_dev/demo_plotters/bar_categorical/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/contour_surface/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/distribution/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/image_matrix/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/line_curve/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/{plot_plot.py → line_curve/plot_plot.py} +3 -2
- figrecipe/_dev/demo_plotters/scatter_points/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/special/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/{plot_pie.py → special/plot_pie.py} +5 -1
- figrecipe/_dev/demo_plotters/spectral_signal/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/vector_flow/__init__.py +4 -0
- figrecipe/_editor/__init__.py +61 -13
- figrecipe/_editor/_bbox/__init__.py +43 -0
- figrecipe/_editor/_bbox/_collections.py +177 -0
- figrecipe/_editor/_bbox/_elements.py +159 -0
- figrecipe/_editor/_bbox/_extract.py +402 -0
- figrecipe/_editor/_bbox/_extract_axes.py +370 -0
- figrecipe/_editor/_bbox/_extract_text.py +466 -0
- figrecipe/_editor/_bbox/_lines.py +173 -0
- figrecipe/_editor/_bbox/_transforms.py +146 -0
- figrecipe/_editor/_call_overrides.py +183 -0
- figrecipe/_editor/_datatable_plot_handlers.py +249 -0
- figrecipe/_editor/_figure_layout.py +211 -0
- figrecipe/_editor/_flask_app.py +200 -1030
- figrecipe/_editor/_helpers.py +251 -0
- figrecipe/_editor/_hitmap/__init__.py +76 -0
- figrecipe/_editor/_hitmap/_artists/__init__.py +21 -0
- figrecipe/_editor/_hitmap/_artists/_collections.py +345 -0
- figrecipe/_editor/_hitmap/_artists/_images.py +68 -0
- figrecipe/_editor/_hitmap/_artists/_lines.py +107 -0
- figrecipe/_editor/_hitmap/_artists/_patches.py +163 -0
- figrecipe/_editor/_hitmap/_artists/_text.py +190 -0
- figrecipe/_editor/_hitmap/_colors.py +181 -0
- figrecipe/_editor/_hitmap/_detect.py +194 -0
- figrecipe/_editor/_hitmap/_restore.py +154 -0
- figrecipe/_editor/_hitmap_main.py +182 -0
- figrecipe/_editor/_overrides.py +4 -1
- figrecipe/_editor/_plot_types_registry.py +190 -0
- figrecipe/_editor/_preferences.py +135 -0
- figrecipe/_editor/_render_overrides.py +507 -0
- figrecipe/_editor/_renderer.py +81 -186
- figrecipe/_editor/_routes_annotation.py +114 -0
- figrecipe/_editor/_routes_axis.py +482 -0
- figrecipe/_editor/_routes_captions.py +130 -0
- figrecipe/_editor/_routes_composition.py +270 -0
- figrecipe/_editor/_routes_core.py +126 -0
- figrecipe/_editor/_routes_datatable.py +364 -0
- figrecipe/_editor/_routes_element.py +335 -0
- figrecipe/_editor/_routes_files.py +443 -0
- figrecipe/_editor/_routes_image.py +200 -0
- figrecipe/_editor/_routes_snapshot.py +94 -0
- figrecipe/_editor/_routes_style.py +243 -0
- figrecipe/_editor/_templates/__init__.py +116 -1
- figrecipe/_editor/_templates/_html.py +154 -64
- figrecipe/_editor/_templates/_html_components/__init__.py +13 -0
- figrecipe/_editor/_templates/_html_components/_composition_toolbar.py +79 -0
- figrecipe/_editor/_templates/_html_components/_file_browser.py +41 -0
- figrecipe/_editor/_templates/_html_datatable.py +92 -0
- figrecipe/_editor/_templates/_scripts/__init__.py +178 -0
- figrecipe/_editor/_templates/_scripts/_accordion.py +328 -0
- figrecipe/_editor/_templates/_scripts/_annotation_drag.py +504 -0
- figrecipe/_editor/_templates/_scripts/_api.py +228 -0
- figrecipe/_editor/_templates/_scripts/_canvas_context_menu.py +182 -0
- figrecipe/_editor/_templates/_scripts/_captions.py +231 -0
- figrecipe/_editor/_templates/_scripts/_colors.py +485 -0
- figrecipe/_editor/_templates/_scripts/_composition.py +283 -0
- figrecipe/_editor/_templates/_scripts/_core.py +493 -0
- figrecipe/_editor/_templates/_scripts/_datatable/__init__.py +59 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_cell_edit.py +97 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_clipboard.py +164 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_context_menu.py +221 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_core.py +150 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_editable.py +511 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_import.py +161 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_plot.py +261 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_selection.py +438 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_table.py +256 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_tabs.py +354 -0
- figrecipe/_editor/_templates/_scripts/_debug_snapshot.py +186 -0
- figrecipe/_editor/_templates/_scripts/_element_editor.py +325 -0
- figrecipe/_editor/_templates/_scripts/_files.py +429 -0
- figrecipe/_editor/_templates/_scripts/_files_context_menu.py +240 -0
- figrecipe/_editor/_templates/_scripts/_hitmap.py +512 -0
- figrecipe/_editor/_templates/_scripts/_image_drop.py +428 -0
- figrecipe/_editor/_templates/_scripts/_inspector.py +315 -0
- figrecipe/_editor/_templates/_scripts/_labels.py +464 -0
- figrecipe/_editor/_templates/_scripts/_legend_drag.py +270 -0
- figrecipe/_editor/_templates/_scripts/_modals.py +226 -0
- figrecipe/_editor/_templates/_scripts/_multi_select.py +198 -0
- figrecipe/_editor/_templates/_scripts/_overlays.py +292 -0
- figrecipe/_editor/_templates/_scripts/_panel_drag.py +505 -0
- figrecipe/_editor/_templates/_scripts/_panel_drag_snapshot.py +33 -0
- figrecipe/_editor/_templates/_scripts/_panel_position.py +463 -0
- figrecipe/_editor/_templates/_scripts/_panel_resize.py +230 -0
- figrecipe/_editor/_templates/_scripts/_panel_snap.py +307 -0
- figrecipe/_editor/_templates/_scripts/_region_select.py +255 -0
- figrecipe/_editor/_templates/_scripts/_selection.py +244 -0
- figrecipe/_editor/_templates/_scripts/_sync.py +242 -0
- figrecipe/_editor/_templates/_scripts/_tabs.py +89 -0
- figrecipe/_editor/_templates/_scripts/_undo_redo.py +348 -0
- figrecipe/_editor/_templates/_scripts/_view_mode.py +107 -0
- figrecipe/_editor/_templates/_scripts/_zoom.py +212 -0
- figrecipe/_editor/_templates/_styles/__init__.py +78 -0
- figrecipe/_editor/_templates/_styles/_base.py +111 -0
- figrecipe/_editor/_templates/_styles/_buttons.py +327 -0
- figrecipe/_editor/_templates/_styles/_color_input.py +123 -0
- figrecipe/_editor/_templates/_styles/_composition.py +87 -0
- figrecipe/_editor/_templates/_styles/_controls.py +430 -0
- figrecipe/_editor/_templates/_styles/_datatable/__init__.py +40 -0
- figrecipe/_editor/_templates/_styles/_datatable/_editable.py +203 -0
- figrecipe/_editor/_templates/_styles/_datatable/_panel.py +268 -0
- figrecipe/_editor/_templates/_styles/_datatable/_table.py +479 -0
- figrecipe/_editor/_templates/_styles/_datatable/_toolbar.py +384 -0
- figrecipe/_editor/_templates/_styles/_datatable/_vars.py +123 -0
- figrecipe/_editor/_templates/_styles/_dynamic_props.py +144 -0
- figrecipe/_editor/_templates/_styles/_file_browser.py +466 -0
- figrecipe/_editor/_templates/_styles/_forms.py +224 -0
- figrecipe/_editor/_templates/_styles/_hitmap.py +191 -0
- figrecipe/_editor/_templates/_styles/_inspector.py +90 -0
- figrecipe/_editor/_templates/_styles/_labels.py +118 -0
- figrecipe/_editor/_templates/_styles/_modals.py +127 -0
- figrecipe/_editor/_templates/_styles/_overlays.py +130 -0
- figrecipe/_editor/_templates/_styles/_preview.py +430 -0
- figrecipe/_editor/_templates/_styles/_selection.py +73 -0
- figrecipe/_editor/_templates/_styles/_spinner.py +117 -0
- figrecipe/_editor/static/audio/click.mp3 +0 -0
- figrecipe/_editor/static/click.mp3 +0 -0
- figrecipe/_editor/static/icons/favicon.ico +0 -0
- figrecipe/_integrations/__init__.py +17 -0
- figrecipe/_integrations/_scitex_stats.py +298 -0
- figrecipe/_params/_DECORATION_METHODS.py +8 -0
- figrecipe/_recorder.py +63 -109
- figrecipe/_recorder_utils.py +124 -0
- figrecipe/_reproducer/__init__.py +18 -0
- figrecipe/_reproducer/_core.py +509 -0
- figrecipe/_reproducer/_custom_plots.py +279 -0
- figrecipe/_reproducer/_seaborn.py +100 -0
- figrecipe/_reproducer/_violin.py +186 -0
- figrecipe/_signatures/_kwargs.py +273 -0
- figrecipe/_signatures/_loader.py +21 -423
- figrecipe/_signatures/_parsing.py +147 -0
- figrecipe/_utils/__init__.py +3 -0
- figrecipe/_utils/_bundle.py +205 -0
- figrecipe/_wrappers/_axes.py +252 -895
- figrecipe/_wrappers/_axes_helpers.py +136 -0
- figrecipe/_wrappers/_axes_plots.py +418 -0
- figrecipe/_wrappers/_axes_seaborn.py +157 -0
- figrecipe/_wrappers/_caption_generator.py +218 -0
- figrecipe/_wrappers/_figure.py +188 -1
- figrecipe/_wrappers/_panel_labels.py +127 -0
- figrecipe/_wrappers/_plot_helpers.py +143 -0
- figrecipe/_wrappers/_stat_annotation.py +274 -0
- figrecipe/_wrappers/_violin_helpers.py +180 -0
- figrecipe/styles/__init__.py +8 -6
- figrecipe/styles/_dotdict.py +72 -0
- figrecipe/styles/_finalize.py +134 -0
- figrecipe/styles/_fonts.py +77 -0
- figrecipe/styles/_kwargs_converter.py +178 -0
- figrecipe/styles/_plot_styles.py +209 -0
- figrecipe/styles/_style_applier.py +42 -480
- figrecipe/styles/_style_loader.py +16 -192
- figrecipe/styles/_themes.py +151 -0
- figrecipe/styles/presets/MATPLOTLIB.yaml +2 -1
- figrecipe/styles/presets/SCITEX.yaml +40 -28
- figrecipe-0.9.0.dist-info/METADATA +427 -0
- figrecipe-0.9.0.dist-info/RECORD +277 -0
- figrecipe-0.9.0.dist-info/entry_points.txt +2 -0
- figrecipe/_editor/_bbox.py +0 -978
- figrecipe/_editor/_hitmap.py +0 -937
- figrecipe/_editor/_templates/_scripts.py +0 -2778
- figrecipe/_editor/_templates/_styles.py +0 -1326
- figrecipe/_reproducer.py +0 -975
- figrecipe-0.6.0.dist-info/METADATA +0 -394
- figrecipe-0.6.0.dist-info/RECORD +0 -90
- /figrecipe/_dev/demo_plotters/{plot_bar.py → bar_categorical/plot_bar.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_barh.py → bar_categorical/plot_barh.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_contour.py → contour_surface/plot_contour.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_contourf.py → contour_surface/plot_contourf.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_tricontour.py → contour_surface/plot_tricontour.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_tricontourf.py → contour_surface/plot_tricontourf.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_tripcolor.py → contour_surface/plot_tripcolor.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_triplot.py → contour_surface/plot_triplot.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_boxplot.py → distribution/plot_boxplot.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_ecdf.py → distribution/plot_ecdf.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_hist.py → distribution/plot_hist.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_hist2d.py → distribution/plot_hist2d.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_violinplot.py → distribution/plot_violinplot.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_hexbin.py → image_matrix/plot_hexbin.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_imshow.py → image_matrix/plot_imshow.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_matshow.py → image_matrix/plot_matshow.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_pcolor.py → image_matrix/plot_pcolor.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_pcolormesh.py → image_matrix/plot_pcolormesh.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_spy.py → image_matrix/plot_spy.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_errorbar.py → line_curve/plot_errorbar.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_fill.py → line_curve/plot_fill.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_fill_between.py → line_curve/plot_fill_between.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_fill_betweenx.py → line_curve/plot_fill_betweenx.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_stackplot.py → line_curve/plot_stackplot.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_stairs.py → line_curve/plot_stairs.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_step.py → line_curve/plot_step.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_scatter.py → scatter_points/plot_scatter.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_eventplot.py → special/plot_eventplot.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_loglog.py → special/plot_loglog.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_semilogx.py → special/plot_semilogx.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_semilogy.py → special/plot_semilogy.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_stem.py → special/plot_stem.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_acorr.py → spectral_signal/plot_acorr.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_angle_spectrum.py → spectral_signal/plot_angle_spectrum.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_cohere.py → spectral_signal/plot_cohere.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_csd.py → spectral_signal/plot_csd.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_magnitude_spectrum.py → spectral_signal/plot_magnitude_spectrum.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_phase_spectrum.py → spectral_signal/plot_phase_spectrum.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_psd.py → spectral_signal/plot_psd.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_specgram.py → spectral_signal/plot_specgram.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_xcorr.py → spectral_signal/plot_xcorr.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_barbs.py → vector_flow/plot_barbs.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_quiver.py → vector_flow/plot_quiver.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_streamplot.py → vector_flow/plot_streamplot.py} +0 -0
- {figrecipe-0.6.0.dist-info → figrecipe-0.9.0.dist-info}/WHEEL +0 -0
- {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"]
|