figrecipe 0.7.4__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 +74 -76
- figrecipe/__main__.py +12 -0
- figrecipe/_api/_panel.py +67 -0
- figrecipe/_api/_save.py +100 -4
- 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 +2 -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/_editor/__init__.py +36 -36
- figrecipe/_editor/_bbox/_extract.py +155 -9
- figrecipe/_editor/_bbox/_extract_text.py +124 -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 +157 -16
- figrecipe/_editor/_helpers.py +17 -8
- figrecipe/_editor/_hitmap/_detect.py +89 -32
- figrecipe/_editor/_hitmap_main.py +4 -4
- figrecipe/_editor/_overrides.py +4 -1
- figrecipe/_editor/_plot_types_registry.py +190 -0
- figrecipe/_editor/_render_overrides.py +38 -11
- figrecipe/_editor/_renderer.py +46 -1
- figrecipe/_editor/_routes_annotation.py +114 -0
- figrecipe/_editor/_routes_axis.py +35 -6
- figrecipe/_editor/_routes_captions.py +130 -0
- figrecipe/_editor/_routes_composition.py +270 -0
- figrecipe/_editor/_routes_core.py +15 -173
- figrecipe/_editor/_routes_datatable.py +364 -0
- figrecipe/_editor/_routes_element.py +37 -19
- 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 +28 -8
- figrecipe/_editor/_templates/__init__.py +40 -2
- figrecipe/_editor/_templates/_html.py +97 -103
- 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 +58 -0
- figrecipe/_editor/_templates/_scripts/_accordion.py +328 -0
- figrecipe/_editor/_templates/_scripts/_annotation_drag.py +504 -0
- figrecipe/_editor/_templates/_scripts/_api.py +1 -1
- figrecipe/_editor/_templates/_scripts/_canvas_context_menu.py +182 -0
- figrecipe/_editor/_templates/_scripts/_captions.py +231 -0
- figrecipe/_editor/_templates/_scripts/_composition.py +283 -0
- figrecipe/_editor/_templates/_scripts/_core.py +94 -37
- 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/_element_editor.py +17 -2
- figrecipe/_editor/_templates/_scripts/_files.py +274 -40
- figrecipe/_editor/_templates/_scripts/_files_context_menu.py +240 -0
- figrecipe/_editor/_templates/_scripts/_hitmap.py +87 -84
- figrecipe/_editor/_templates/_scripts/_image_drop.py +428 -0
- figrecipe/_editor/_templates/_scripts/_legend_drag.py +5 -0
- figrecipe/_editor/_templates/_scripts/_multi_select.py +198 -0
- figrecipe/_editor/_templates/_scripts/_panel_drag.py +219 -48
- figrecipe/_editor/_templates/_scripts/_panel_drag_snapshot.py +33 -0
- figrecipe/_editor/_templates/_scripts/_panel_position.py +238 -54
- 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 +8 -1
- figrecipe/_editor/_templates/_scripts/_sync.py +242 -0
- figrecipe/_editor/_templates/_scripts/_undo_redo.py +348 -0
- figrecipe/_editor/_templates/_scripts/_zoom.py +52 -19
- figrecipe/_editor/_templates/_styles/__init__.py +9 -0
- figrecipe/_editor/_templates/_styles/_base.py +47 -0
- figrecipe/_editor/_templates/_styles/_buttons.py +127 -6
- figrecipe/_editor/_templates/_styles/_composition.py +87 -0
- figrecipe/_editor/_templates/_styles/_controls.py +168 -3
- 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 +5 -5
- figrecipe/_editor/_templates/_styles/_file_browser.py +466 -0
- figrecipe/_editor/_templates/_styles/_forms.py +98 -0
- figrecipe/_editor/_templates/_styles/_hitmap.py +7 -0
- figrecipe/_editor/_templates/_styles/_modals.py +29 -0
- figrecipe/_editor/_templates/_styles/_overlays.py +5 -5
- figrecipe/_editor/_templates/_styles/_preview.py +213 -8
- 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 +2 -0
- figrecipe/_recorder.py +28 -3
- figrecipe/_reproducer/_core.py +60 -49
- figrecipe/_utils/__init__.py +3 -0
- figrecipe/_utils/_bundle.py +205 -0
- figrecipe/_wrappers/_axes.py +150 -2
- figrecipe/_wrappers/_caption_generator.py +218 -0
- figrecipe/_wrappers/_figure.py +26 -1
- figrecipe/_wrappers/_stat_annotation.py +274 -0
- figrecipe/styles/_style_applier.py +10 -2
- figrecipe/styles/presets/SCITEX.yaml +11 -4
- {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/METADATA +144 -146
- figrecipe-0.9.0.dist-info/RECORD +277 -0
- figrecipe-0.9.0.dist-info/entry_points.txt +2 -0
- figrecipe-0.7.4.dist-info/RECORD +0 -188
- {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/WHEEL +0 -0
- {figrecipe-0.7.4.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"]
|
|
@@ -77,9 +77,15 @@ def register_element_routes(app, editor):
|
|
|
77
77
|
|
|
78
78
|
@app.route("/update_call", methods=["POST"])
|
|
79
79
|
def update_call():
|
|
80
|
-
"""Update a call's kwargs and re-render.
|
|
81
|
-
from .._reproducer import reproduce_from_record
|
|
80
|
+
"""Update a call's kwargs and re-render.
|
|
82
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
|
+
"""
|
|
83
89
|
data = request.get_json() or {}
|
|
84
90
|
call_id = data.get("call_id")
|
|
85
91
|
param = data.get("param")
|
|
@@ -88,27 +94,21 @@ def register_element_routes(app, editor):
|
|
|
88
94
|
if not call_id or not param:
|
|
89
95
|
return jsonify({"error": "Missing call_id or param"}), 400
|
|
90
96
|
|
|
97
|
+
# Find the call and store override
|
|
91
98
|
updated = False
|
|
92
99
|
if hasattr(editor.fig, "record"):
|
|
93
100
|
for ax_key, ax_record in editor.fig.record.axes.items():
|
|
94
101
|
for call in ax_record.calls:
|
|
95
102
|
if call.id == call_id:
|
|
96
|
-
#
|
|
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
|
-
|
|
103
|
+
# Store override - will be applied via apply_overrides()
|
|
102
104
|
editor.style_overrides.set_call_override(call_id, param, value)
|
|
103
105
|
|
|
106
|
+
# Also update record kwargs for persistence
|
|
104
107
|
if value is None or value == "" or value == "null":
|
|
105
108
|
call.kwargs.pop(param, None)
|
|
106
109
|
else:
|
|
107
110
|
call.kwargs[param] = value
|
|
108
111
|
|
|
109
|
-
print(
|
|
110
|
-
f"[DEBUG] After: call.kwargs[{param}] = {call.kwargs.get(param)}"
|
|
111
|
-
)
|
|
112
112
|
updated = True
|
|
113
113
|
break
|
|
114
114
|
if updated:
|
|
@@ -117,19 +117,23 @@ def register_element_routes(app, editor):
|
|
|
117
117
|
if not updated:
|
|
118
118
|
return jsonify({"error": f"Call {call_id} not found"}), 404
|
|
119
119
|
|
|
120
|
-
|
|
121
|
-
|
|
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}")
|
|
122
126
|
|
|
123
|
-
|
|
127
|
+
try:
|
|
128
|
+
# IDENTICAL to all other routes - single source of truth
|
|
124
129
|
base64_img, bboxes, img_size = render_with_overrides(
|
|
125
|
-
|
|
126
|
-
|
|
130
|
+
editor.fig,
|
|
131
|
+
editor.get_effective_style(),
|
|
127
132
|
editor.dark_mode,
|
|
128
133
|
)
|
|
129
134
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
hitmap_img, color_map = generate_hitmap(new_fig, img_size[0], img_size[1])
|
|
135
|
+
# Regenerate hitmap
|
|
136
|
+
hitmap_img, color_map = generate_hitmap(editor.fig, dpi=150)
|
|
133
137
|
editor._color_map = color_map
|
|
134
138
|
editor._hitmap_base64 = hitmap_to_base64(hitmap_img)
|
|
135
139
|
editor._hitmap_generated = True
|
|
@@ -140,6 +144,19 @@ def register_element_routes(app, editor):
|
|
|
140
144
|
traceback.print_exc()
|
|
141
145
|
return jsonify({"error": f"Re-render failed: {str(e)}"}), 500
|
|
142
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
|
+
|
|
143
160
|
return jsonify(
|
|
144
161
|
{
|
|
145
162
|
"success": True,
|
|
@@ -150,6 +167,7 @@ def register_element_routes(app, editor):
|
|
|
150
167
|
"param": param,
|
|
151
168
|
"value": value,
|
|
152
169
|
"has_call_overrides": editor.style_overrides.has_call_overrides(),
|
|
170
|
+
"updated_call": updated_call_data,
|
|
153
171
|
}
|
|
154
172
|
)
|
|
155
173
|
|