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.
Files changed (143) hide show
  1. figrecipe/__init__.py +74 -76
  2. figrecipe/__main__.py +12 -0
  3. figrecipe/_api/_panel.py +67 -0
  4. figrecipe/_api/_save.py +100 -4
  5. figrecipe/_cli/__init__.py +7 -0
  6. figrecipe/_cli/_compose.py +87 -0
  7. figrecipe/_cli/_convert.py +117 -0
  8. figrecipe/_cli/_crop.py +82 -0
  9. figrecipe/_cli/_edit.py +70 -0
  10. figrecipe/_cli/_extract.py +128 -0
  11. figrecipe/_cli/_fonts.py +47 -0
  12. figrecipe/_cli/_info.py +67 -0
  13. figrecipe/_cli/_main.py +58 -0
  14. figrecipe/_cli/_reproduce.py +79 -0
  15. figrecipe/_cli/_style.py +77 -0
  16. figrecipe/_cli/_validate.py +66 -0
  17. figrecipe/_cli/_version.py +50 -0
  18. figrecipe/_composition/__init__.py +32 -0
  19. figrecipe/_composition/_alignment.py +452 -0
  20. figrecipe/_composition/_compose.py +179 -0
  21. figrecipe/_composition/_import_axes.py +127 -0
  22. figrecipe/_composition/_visibility.py +125 -0
  23. figrecipe/_dev/__init__.py +2 -0
  24. figrecipe/_dev/browser/__init__.py +69 -0
  25. figrecipe/_dev/browser/_audio.py +240 -0
  26. figrecipe/_dev/browser/_caption.py +356 -0
  27. figrecipe/_dev/browser/_click_effect.py +146 -0
  28. figrecipe/_dev/browser/_cursor.py +196 -0
  29. figrecipe/_dev/browser/_highlight.py +105 -0
  30. figrecipe/_dev/browser/_narration.py +237 -0
  31. figrecipe/_dev/browser/_recorder.py +446 -0
  32. figrecipe/_dev/browser/_utils.py +178 -0
  33. figrecipe/_dev/browser/_video_trim/__init__.py +152 -0
  34. figrecipe/_dev/browser/_video_trim/_detection.py +223 -0
  35. figrecipe/_dev/browser/_video_trim/_markers.py +140 -0
  36. figrecipe/_editor/__init__.py +36 -36
  37. figrecipe/_editor/_bbox/_extract.py +155 -9
  38. figrecipe/_editor/_bbox/_extract_text.py +124 -0
  39. figrecipe/_editor/_call_overrides.py +183 -0
  40. figrecipe/_editor/_datatable_plot_handlers.py +249 -0
  41. figrecipe/_editor/_figure_layout.py +211 -0
  42. figrecipe/_editor/_flask_app.py +157 -16
  43. figrecipe/_editor/_helpers.py +17 -8
  44. figrecipe/_editor/_hitmap/_detect.py +89 -32
  45. figrecipe/_editor/_hitmap_main.py +4 -4
  46. figrecipe/_editor/_overrides.py +4 -1
  47. figrecipe/_editor/_plot_types_registry.py +190 -0
  48. figrecipe/_editor/_render_overrides.py +38 -11
  49. figrecipe/_editor/_renderer.py +46 -1
  50. figrecipe/_editor/_routes_annotation.py +114 -0
  51. figrecipe/_editor/_routes_axis.py +35 -6
  52. figrecipe/_editor/_routes_captions.py +130 -0
  53. figrecipe/_editor/_routes_composition.py +270 -0
  54. figrecipe/_editor/_routes_core.py +15 -173
  55. figrecipe/_editor/_routes_datatable.py +364 -0
  56. figrecipe/_editor/_routes_element.py +37 -19
  57. figrecipe/_editor/_routes_files.py +443 -0
  58. figrecipe/_editor/_routes_image.py +200 -0
  59. figrecipe/_editor/_routes_snapshot.py +94 -0
  60. figrecipe/_editor/_routes_style.py +28 -8
  61. figrecipe/_editor/_templates/__init__.py +40 -2
  62. figrecipe/_editor/_templates/_html.py +97 -103
  63. figrecipe/_editor/_templates/_html_components/__init__.py +13 -0
  64. figrecipe/_editor/_templates/_html_components/_composition_toolbar.py +79 -0
  65. figrecipe/_editor/_templates/_html_components/_file_browser.py +41 -0
  66. figrecipe/_editor/_templates/_html_datatable.py +92 -0
  67. figrecipe/_editor/_templates/_scripts/__init__.py +58 -0
  68. figrecipe/_editor/_templates/_scripts/_accordion.py +328 -0
  69. figrecipe/_editor/_templates/_scripts/_annotation_drag.py +504 -0
  70. figrecipe/_editor/_templates/_scripts/_api.py +1 -1
  71. figrecipe/_editor/_templates/_scripts/_canvas_context_menu.py +182 -0
  72. figrecipe/_editor/_templates/_scripts/_captions.py +231 -0
  73. figrecipe/_editor/_templates/_scripts/_composition.py +283 -0
  74. figrecipe/_editor/_templates/_scripts/_core.py +94 -37
  75. figrecipe/_editor/_templates/_scripts/_datatable/__init__.py +59 -0
  76. figrecipe/_editor/_templates/_scripts/_datatable/_cell_edit.py +97 -0
  77. figrecipe/_editor/_templates/_scripts/_datatable/_clipboard.py +164 -0
  78. figrecipe/_editor/_templates/_scripts/_datatable/_context_menu.py +221 -0
  79. figrecipe/_editor/_templates/_scripts/_datatable/_core.py +150 -0
  80. figrecipe/_editor/_templates/_scripts/_datatable/_editable.py +511 -0
  81. figrecipe/_editor/_templates/_scripts/_datatable/_import.py +161 -0
  82. figrecipe/_editor/_templates/_scripts/_datatable/_plot.py +261 -0
  83. figrecipe/_editor/_templates/_scripts/_datatable/_selection.py +438 -0
  84. figrecipe/_editor/_templates/_scripts/_datatable/_table.py +256 -0
  85. figrecipe/_editor/_templates/_scripts/_datatable/_tabs.py +354 -0
  86. figrecipe/_editor/_templates/_scripts/_element_editor.py +17 -2
  87. figrecipe/_editor/_templates/_scripts/_files.py +274 -40
  88. figrecipe/_editor/_templates/_scripts/_files_context_menu.py +240 -0
  89. figrecipe/_editor/_templates/_scripts/_hitmap.py +87 -84
  90. figrecipe/_editor/_templates/_scripts/_image_drop.py +428 -0
  91. figrecipe/_editor/_templates/_scripts/_legend_drag.py +5 -0
  92. figrecipe/_editor/_templates/_scripts/_multi_select.py +198 -0
  93. figrecipe/_editor/_templates/_scripts/_panel_drag.py +219 -48
  94. figrecipe/_editor/_templates/_scripts/_panel_drag_snapshot.py +33 -0
  95. figrecipe/_editor/_templates/_scripts/_panel_position.py +238 -54
  96. figrecipe/_editor/_templates/_scripts/_panel_resize.py +230 -0
  97. figrecipe/_editor/_templates/_scripts/_panel_snap.py +307 -0
  98. figrecipe/_editor/_templates/_scripts/_region_select.py +255 -0
  99. figrecipe/_editor/_templates/_scripts/_selection.py +8 -1
  100. figrecipe/_editor/_templates/_scripts/_sync.py +242 -0
  101. figrecipe/_editor/_templates/_scripts/_undo_redo.py +348 -0
  102. figrecipe/_editor/_templates/_scripts/_zoom.py +52 -19
  103. figrecipe/_editor/_templates/_styles/__init__.py +9 -0
  104. figrecipe/_editor/_templates/_styles/_base.py +47 -0
  105. figrecipe/_editor/_templates/_styles/_buttons.py +127 -6
  106. figrecipe/_editor/_templates/_styles/_composition.py +87 -0
  107. figrecipe/_editor/_templates/_styles/_controls.py +168 -3
  108. figrecipe/_editor/_templates/_styles/_datatable/__init__.py +40 -0
  109. figrecipe/_editor/_templates/_styles/_datatable/_editable.py +203 -0
  110. figrecipe/_editor/_templates/_styles/_datatable/_panel.py +268 -0
  111. figrecipe/_editor/_templates/_styles/_datatable/_table.py +479 -0
  112. figrecipe/_editor/_templates/_styles/_datatable/_toolbar.py +384 -0
  113. figrecipe/_editor/_templates/_styles/_datatable/_vars.py +123 -0
  114. figrecipe/_editor/_templates/_styles/_dynamic_props.py +5 -5
  115. figrecipe/_editor/_templates/_styles/_file_browser.py +466 -0
  116. figrecipe/_editor/_templates/_styles/_forms.py +98 -0
  117. figrecipe/_editor/_templates/_styles/_hitmap.py +7 -0
  118. figrecipe/_editor/_templates/_styles/_modals.py +29 -0
  119. figrecipe/_editor/_templates/_styles/_overlays.py +5 -5
  120. figrecipe/_editor/_templates/_styles/_preview.py +213 -8
  121. figrecipe/_editor/_templates/_styles/_spinner.py +117 -0
  122. figrecipe/_editor/static/audio/click.mp3 +0 -0
  123. figrecipe/_editor/static/click.mp3 +0 -0
  124. figrecipe/_editor/static/icons/favicon.ico +0 -0
  125. figrecipe/_integrations/__init__.py +17 -0
  126. figrecipe/_integrations/_scitex_stats.py +298 -0
  127. figrecipe/_params/_DECORATION_METHODS.py +2 -0
  128. figrecipe/_recorder.py +28 -3
  129. figrecipe/_reproducer/_core.py +60 -49
  130. figrecipe/_utils/__init__.py +3 -0
  131. figrecipe/_utils/_bundle.py +205 -0
  132. figrecipe/_wrappers/_axes.py +150 -2
  133. figrecipe/_wrappers/_caption_generator.py +218 -0
  134. figrecipe/_wrappers/_figure.py +26 -1
  135. figrecipe/_wrappers/_stat_annotation.py +274 -0
  136. figrecipe/styles/_style_applier.py +10 -2
  137. figrecipe/styles/presets/SCITEX.yaml +11 -4
  138. {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/METADATA +144 -146
  139. figrecipe-0.9.0.dist-info/RECORD +277 -0
  140. figrecipe-0.9.0.dist-info/entry_points.txt +2 -0
  141. figrecipe-0.7.4.dist-info/RECORD +0 -188
  142. {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/WHEEL +0 -0
  143. {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
- # Debug: log the update
97
- print(f"[DEBUG] update_call: {call_id}.{param} = {value}")
98
- print(
99
- f"[DEBUG] Before: call.kwargs[{param}] = {call.kwargs.get(param)}"
100
- )
101
-
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
- try:
121
- new_fig, _ = reproduce_from_record(editor.fig.record)
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
- effective_style = editor.get_effective_style()
127
+ try:
128
+ # IDENTICAL to all other routes - single source of truth
124
129
  base64_img, bboxes, img_size = render_with_overrides(
125
- new_fig,
126
- effective_style if effective_style else None,
130
+ editor.fig,
131
+ editor.get_effective_style(),
127
132
  editor.dark_mode,
128
133
  )
129
134
 
130
- editor.fig = new_fig
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