figrecipe 0.5.0__py3-none-any.whl → 0.6.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 +361 -93
- figrecipe/_dev/__init__.py +120 -0
- figrecipe/_dev/demo_plotters/__init__.py +195 -0
- figrecipe/_dev/demo_plotters/plot_acorr.py +24 -0
- figrecipe/_dev/demo_plotters/plot_angle_spectrum.py +28 -0
- figrecipe/_dev/demo_plotters/plot_bar.py +25 -0
- figrecipe/_dev/demo_plotters/plot_barbs.py +30 -0
- figrecipe/_dev/demo_plotters/plot_barh.py +25 -0
- figrecipe/_dev/demo_plotters/plot_boxplot.py +24 -0
- figrecipe/_dev/demo_plotters/plot_cohere.py +29 -0
- figrecipe/_dev/demo_plotters/plot_contour.py +30 -0
- figrecipe/_dev/demo_plotters/plot_contourf.py +29 -0
- figrecipe/_dev/demo_plotters/plot_csd.py +29 -0
- figrecipe/_dev/demo_plotters/plot_ecdf.py +24 -0
- figrecipe/_dev/demo_plotters/plot_errorbar.py +28 -0
- figrecipe/_dev/demo_plotters/plot_eventplot.py +25 -0
- figrecipe/_dev/demo_plotters/plot_fill.py +29 -0
- figrecipe/_dev/demo_plotters/plot_fill_between.py +30 -0
- figrecipe/_dev/demo_plotters/plot_fill_betweenx.py +28 -0
- figrecipe/_dev/demo_plotters/plot_hexbin.py +25 -0
- figrecipe/_dev/demo_plotters/plot_hist.py +24 -0
- figrecipe/_dev/demo_plotters/plot_hist2d.py +25 -0
- figrecipe/_dev/demo_plotters/plot_imshow.py +23 -0
- figrecipe/_dev/demo_plotters/plot_loglog.py +27 -0
- figrecipe/_dev/demo_plotters/plot_magnitude_spectrum.py +28 -0
- figrecipe/_dev/demo_plotters/plot_matshow.py +23 -0
- figrecipe/_dev/demo_plotters/plot_pcolor.py +29 -0
- figrecipe/_dev/demo_plotters/plot_pcolormesh.py +29 -0
- figrecipe/_dev/demo_plotters/plot_phase_spectrum.py +28 -0
- figrecipe/_dev/demo_plotters/plot_pie.py +23 -0
- figrecipe/_dev/demo_plotters/plot_plot.py +27 -0
- figrecipe/_dev/demo_plotters/plot_psd.py +29 -0
- figrecipe/_dev/demo_plotters/plot_quiver.py +30 -0
- figrecipe/_dev/demo_plotters/plot_scatter.py +24 -0
- figrecipe/_dev/demo_plotters/plot_semilogx.py +27 -0
- figrecipe/_dev/demo_plotters/plot_semilogy.py +27 -0
- figrecipe/_dev/demo_plotters/plot_specgram.py +30 -0
- figrecipe/_dev/demo_plotters/plot_spy.py +29 -0
- figrecipe/_dev/demo_plotters/plot_stackplot.py +29 -0
- figrecipe/_dev/demo_plotters/plot_stairs.py +27 -0
- figrecipe/_dev/demo_plotters/plot_stem.py +27 -0
- figrecipe/_dev/demo_plotters/plot_step.py +27 -0
- figrecipe/_dev/demo_plotters/plot_streamplot.py +30 -0
- figrecipe/_dev/demo_plotters/plot_tricontour.py +28 -0
- figrecipe/_dev/demo_plotters/plot_tricontourf.py +28 -0
- figrecipe/_dev/demo_plotters/plot_tripcolor.py +29 -0
- figrecipe/_dev/demo_plotters/plot_triplot.py +25 -0
- figrecipe/_dev/demo_plotters/plot_violinplot.py +25 -0
- figrecipe/_dev/demo_plotters/plot_xcorr.py +25 -0
- figrecipe/_editor/__init__.py +230 -0
- figrecipe/_editor/_bbox.py +978 -0
- figrecipe/_editor/_flask_app.py +1229 -0
- figrecipe/_editor/_hitmap.py +937 -0
- figrecipe/_editor/_overrides.py +318 -0
- figrecipe/_editor/_renderer.py +349 -0
- figrecipe/_editor/_templates/__init__.py +75 -0
- figrecipe/_editor/_templates/_html.py +406 -0
- figrecipe/_editor/_templates/_scripts.py +2778 -0
- figrecipe/_editor/_templates/_styles.py +1326 -0
- figrecipe/_params/_DECORATION_METHODS.py +27 -0
- figrecipe/_params/_PLOTTING_METHODS.py +58 -0
- figrecipe/_params/__init__.py +9 -0
- figrecipe/_recorder.py +126 -73
- figrecipe/_reproducer.py +658 -41
- figrecipe/_seaborn.py +14 -9
- figrecipe/_serializer.py +2 -2
- figrecipe/_signatures/README.md +68 -0
- figrecipe/_signatures/__init__.py +12 -2
- figrecipe/_signatures/_loader.py +515 -56
- figrecipe/_utils/__init__.py +6 -4
- figrecipe/_utils/_crop.py +10 -4
- figrecipe/_utils/_image_diff.py +37 -33
- figrecipe/_utils/_numpy_io.py +0 -1
- figrecipe/_utils/_units.py +11 -3
- figrecipe/_validator.py +12 -3
- figrecipe/_wrappers/_axes.py +860 -46
- figrecipe/_wrappers/_figure.py +115 -18
- figrecipe/plt.py +0 -1
- figrecipe/pyplot.py +2 -1
- figrecipe/styles/__init__.py +9 -10
- figrecipe/styles/_style_applier.py +332 -28
- figrecipe/styles/_style_loader.py +172 -44
- figrecipe/styles/presets/MATPLOTLIB.yaml +94 -0
- figrecipe/styles/presets/SCITEX.yaml +176 -0
- figrecipe-0.6.0.dist-info/METADATA +394 -0
- figrecipe-0.6.0.dist-info/RECORD +90 -0
- figrecipe-0.5.0.dist-info/METADATA +0 -336
- figrecipe-0.5.0.dist-info/RECORD +0 -26
- {figrecipe-0.5.0.dist-info → figrecipe-0.6.0.dist-info}/WHEEL +0 -0
- {figrecipe-0.5.0.dist-info → figrecipe-0.6.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,1229 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Flask-based GUI editor for figure styling.
|
|
5
|
+
|
|
6
|
+
This module provides a web-based interface for interactively adjusting
|
|
7
|
+
figure styles with hitmap-based element selection.
|
|
8
|
+
|
|
9
|
+
Style Override Architecture
|
|
10
|
+
---------------------------
|
|
11
|
+
Styles are managed in layers with separate storage:
|
|
12
|
+
|
|
13
|
+
1. Base style (from preset like SCITEX)
|
|
14
|
+
2. Programmatic style (from code)
|
|
15
|
+
3. Manual overrides (from GUI editor)
|
|
16
|
+
|
|
17
|
+
Manual overrides are stored separately in `.overrides.json` files,
|
|
18
|
+
allowing restoration to original programmatic styles.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
# Force Agg backend before any pyplot import to avoid Tkinter threading issues
|
|
22
|
+
import matplotlib
|
|
23
|
+
|
|
24
|
+
matplotlib.use("Agg")
|
|
25
|
+
|
|
26
|
+
import webbrowser
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Dict, Optional
|
|
29
|
+
|
|
30
|
+
from .._wrappers import RecordingFigure
|
|
31
|
+
from ._overrides import (
|
|
32
|
+
create_overrides_from_style,
|
|
33
|
+
load_overrides,
|
|
34
|
+
save_overrides,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class FigureEditor:
|
|
39
|
+
"""
|
|
40
|
+
Browser-based figure style editor using Flask.
|
|
41
|
+
|
|
42
|
+
Features:
|
|
43
|
+
- Real-time figure preview with style overrides
|
|
44
|
+
- Hitmap-based element selection
|
|
45
|
+
- Full style property editing (dimensions, fonts, lines, colors, etc.)
|
|
46
|
+
- Dark/light theme toggle
|
|
47
|
+
- Download in PNG/SVG/PDF formats
|
|
48
|
+
- Separate storage of manual overrides (can restore to original)
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
fig: RecordingFigure,
|
|
54
|
+
recipe_path: Optional[Path] = None,
|
|
55
|
+
style: Optional[Dict[str, Any]] = None,
|
|
56
|
+
port: int = 5050,
|
|
57
|
+
static_png_path: Optional[Path] = None,
|
|
58
|
+
hitmap_base64: Optional[str] = None,
|
|
59
|
+
color_map: Optional[Dict] = None,
|
|
60
|
+
):
|
|
61
|
+
"""
|
|
62
|
+
Initialize figure editor.
|
|
63
|
+
|
|
64
|
+
Parameters
|
|
65
|
+
----------
|
|
66
|
+
fig : RecordingFigure
|
|
67
|
+
Figure to edit.
|
|
68
|
+
recipe_path : Path, optional
|
|
69
|
+
Path to recipe file (if loaded from file).
|
|
70
|
+
style : dict, optional
|
|
71
|
+
Initial style configuration (programmatic).
|
|
72
|
+
port : int
|
|
73
|
+
Flask server port.
|
|
74
|
+
static_png_path : Path, optional
|
|
75
|
+
Path to pre-rendered static PNG (source of truth for initial display).
|
|
76
|
+
hitmap_base64 : str, optional
|
|
77
|
+
Pre-generated hitmap as base64.
|
|
78
|
+
color_map : dict, optional
|
|
79
|
+
Pre-generated color map for hitmap.
|
|
80
|
+
"""
|
|
81
|
+
self.fig = fig
|
|
82
|
+
self.recipe_path = Path(recipe_path) if recipe_path else None
|
|
83
|
+
self.port = port
|
|
84
|
+
self.dark_mode = False
|
|
85
|
+
|
|
86
|
+
# Pre-rendered static PNG (source of truth)
|
|
87
|
+
self._static_png_path = static_png_path
|
|
88
|
+
self._initial_base64 = None
|
|
89
|
+
if static_png_path and static_png_path.exists():
|
|
90
|
+
import base64
|
|
91
|
+
|
|
92
|
+
with open(static_png_path, "rb") as f:
|
|
93
|
+
self._initial_base64 = base64.b64encode(f.read()).decode("utf-8")
|
|
94
|
+
|
|
95
|
+
# Initialize style overrides system
|
|
96
|
+
self._init_style_overrides(style)
|
|
97
|
+
|
|
98
|
+
# Pre-generated hitmap and color_map
|
|
99
|
+
self._hitmap_base64 = hitmap_base64
|
|
100
|
+
self._color_map = color_map
|
|
101
|
+
|
|
102
|
+
def _init_style_overrides(self, programmatic_style: Optional[Dict[str, Any]]):
|
|
103
|
+
"""Initialize the layered style override system."""
|
|
104
|
+
# Try to load existing overrides
|
|
105
|
+
if self.recipe_path:
|
|
106
|
+
existing = load_overrides(self.recipe_path)
|
|
107
|
+
if existing:
|
|
108
|
+
self.style_overrides = existing
|
|
109
|
+
# Update programmatic style if provided
|
|
110
|
+
if programmatic_style:
|
|
111
|
+
self.style_overrides.programmatic_style = programmatic_style
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
# Get base style from global preset (always ensure we have a base style)
|
|
115
|
+
base_style = {}
|
|
116
|
+
style_name = "SCITEX" # Default
|
|
117
|
+
try:
|
|
118
|
+
from ..styles._style_loader import (
|
|
119
|
+
_CURRENT_STYLE_NAME,
|
|
120
|
+
_STYLE_CACHE,
|
|
121
|
+
load_style,
|
|
122
|
+
to_subplots_kwargs,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# If no style is loaded, load the default SCITEX style
|
|
126
|
+
if _STYLE_CACHE is None:
|
|
127
|
+
load_style("SCITEX")
|
|
128
|
+
|
|
129
|
+
# Get the style cache (now guaranteed to exist)
|
|
130
|
+
from ..styles._style_loader import _STYLE_CACHE
|
|
131
|
+
|
|
132
|
+
if _STYLE_CACHE is not None:
|
|
133
|
+
base_style = to_subplots_kwargs(_STYLE_CACHE)
|
|
134
|
+
style_name = _CURRENT_STYLE_NAME or "SCITEX"
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
# Store the style name for UI display
|
|
139
|
+
self._style_name = style_name
|
|
140
|
+
|
|
141
|
+
# Create new overrides
|
|
142
|
+
self.style_overrides = create_overrides_from_style(
|
|
143
|
+
base_style=base_style,
|
|
144
|
+
programmatic_style=programmatic_style or {},
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def style(self) -> Dict[str, Any]:
|
|
149
|
+
"""Get the original style (without manual overrides)."""
|
|
150
|
+
return self.style_overrides.get_original_style()
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def overrides(self) -> Dict[str, Any]:
|
|
154
|
+
"""Get current manual overrides."""
|
|
155
|
+
return self.style_overrides.manual_overrides
|
|
156
|
+
|
|
157
|
+
@overrides.setter
|
|
158
|
+
def overrides(self, value: Dict[str, Any]):
|
|
159
|
+
"""Set manual overrides."""
|
|
160
|
+
self.style_overrides.manual_overrides = value
|
|
161
|
+
|
|
162
|
+
def get_effective_style(self) -> Dict[str, Any]:
|
|
163
|
+
"""Get the final merged style."""
|
|
164
|
+
return self.style_overrides.get_effective_style()
|
|
165
|
+
|
|
166
|
+
def run(self, open_browser: bool = True) -> Dict[str, Any]:
|
|
167
|
+
"""
|
|
168
|
+
Run the editor server.
|
|
169
|
+
|
|
170
|
+
Parameters
|
|
171
|
+
----------
|
|
172
|
+
open_browser : bool
|
|
173
|
+
Whether to open browser automatically.
|
|
174
|
+
|
|
175
|
+
Returns
|
|
176
|
+
-------
|
|
177
|
+
dict
|
|
178
|
+
Final style overrides after editing session.
|
|
179
|
+
"""
|
|
180
|
+
from flask import Flask, jsonify, render_template_string, request, send_file
|
|
181
|
+
|
|
182
|
+
from ._bbox import extract_bboxes
|
|
183
|
+
from ._hitmap import generate_hitmap, hitmap_to_base64
|
|
184
|
+
from ._renderer import render_download
|
|
185
|
+
from ._templates import build_html_template
|
|
186
|
+
|
|
187
|
+
# Use specified port strictly (no fallback)
|
|
188
|
+
|
|
189
|
+
# Defer hitmap generation until first request (lazy loading)
|
|
190
|
+
# This makes the editor start immediately
|
|
191
|
+
self._hitmap_generated = self._hitmap_base64 is not None
|
|
192
|
+
|
|
193
|
+
# Create Flask app
|
|
194
|
+
app = Flask(__name__)
|
|
195
|
+
editor = self
|
|
196
|
+
|
|
197
|
+
@app.route("/")
|
|
198
|
+
def index():
|
|
199
|
+
"""Main editor page."""
|
|
200
|
+
# Always render with effective style (base + programmatic + manual)
|
|
201
|
+
# to ensure YAML style settings are applied
|
|
202
|
+
base64_img, bboxes, img_size = _render_with_overrides(
|
|
203
|
+
editor.fig,
|
|
204
|
+
editor.get_effective_style(),
|
|
205
|
+
editor.dark_mode,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Get style name (default to SCITEX if not set)
|
|
209
|
+
style_name = getattr(editor, "_style_name", "SCITEX")
|
|
210
|
+
|
|
211
|
+
# Build HTML template
|
|
212
|
+
html = build_html_template(
|
|
213
|
+
image_base64=base64_img,
|
|
214
|
+
bboxes=bboxes,
|
|
215
|
+
color_map=editor._color_map,
|
|
216
|
+
style=editor.style,
|
|
217
|
+
overrides=editor.get_effective_style(),
|
|
218
|
+
img_size=img_size,
|
|
219
|
+
style_name=style_name,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
return render_template_string(html)
|
|
223
|
+
|
|
224
|
+
@app.route("/preview")
|
|
225
|
+
def preview():
|
|
226
|
+
"""Get current preview image."""
|
|
227
|
+
# Always render with effective style (base + programmatic + manual)
|
|
228
|
+
base64_img, bboxes, img_size = _render_with_overrides(
|
|
229
|
+
editor.fig,
|
|
230
|
+
editor.get_effective_style(),
|
|
231
|
+
editor.dark_mode,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
return jsonify(
|
|
235
|
+
{
|
|
236
|
+
"image": base64_img,
|
|
237
|
+
"bboxes": bboxes,
|
|
238
|
+
"img_size": {"width": img_size[0], "height": img_size[1]},
|
|
239
|
+
}
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
@app.route("/update", methods=["POST"])
|
|
243
|
+
def update():
|
|
244
|
+
"""Update preview with new style overrides."""
|
|
245
|
+
data = request.get_json() or {}
|
|
246
|
+
|
|
247
|
+
# Update manual overrides
|
|
248
|
+
editor.overrides.update(data.get("overrides", {}))
|
|
249
|
+
editor.dark_mode = data.get("dark_mode", editor.dark_mode)
|
|
250
|
+
|
|
251
|
+
# Re-render with effective style (base + programmatic + manual)
|
|
252
|
+
base64_img, bboxes, img_size = _render_with_overrides(
|
|
253
|
+
editor.fig,
|
|
254
|
+
editor.get_effective_style(),
|
|
255
|
+
editor.dark_mode,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
return jsonify(
|
|
259
|
+
{
|
|
260
|
+
"image": base64_img,
|
|
261
|
+
"bboxes": bboxes,
|
|
262
|
+
"img_size": {"width": img_size[0], "height": img_size[1]},
|
|
263
|
+
}
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
@app.route("/hitmap")
|
|
267
|
+
def hitmap():
|
|
268
|
+
"""Get hitmap image and color map (lazy generation on first request)."""
|
|
269
|
+
# Generate hitmap on first request if not already done
|
|
270
|
+
if not editor._hitmap_generated:
|
|
271
|
+
print("Generating hitmap (first request)...")
|
|
272
|
+
hitmap_img, editor._color_map = generate_hitmap(editor.fig)
|
|
273
|
+
editor._hitmap_base64 = hitmap_to_base64(hitmap_img)
|
|
274
|
+
editor._hitmap_generated = True
|
|
275
|
+
print("Hitmap ready.")
|
|
276
|
+
|
|
277
|
+
return jsonify(
|
|
278
|
+
{
|
|
279
|
+
"image": editor._hitmap_base64,
|
|
280
|
+
"color_map": editor._color_map,
|
|
281
|
+
}
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
def _to_json_serializable(obj):
|
|
285
|
+
"""Convert numpy arrays and other non-serializable objects to JSON-safe types."""
|
|
286
|
+
import numpy as np
|
|
287
|
+
|
|
288
|
+
if isinstance(obj, np.ndarray):
|
|
289
|
+
return obj.tolist()
|
|
290
|
+
elif isinstance(obj, (np.integer, np.floating)):
|
|
291
|
+
return obj.item()
|
|
292
|
+
elif isinstance(obj, dict):
|
|
293
|
+
return {k: _to_json_serializable(v) for k, v in obj.items()}
|
|
294
|
+
elif isinstance(obj, (list, tuple)):
|
|
295
|
+
return [_to_json_serializable(item) for item in obj]
|
|
296
|
+
return obj
|
|
297
|
+
|
|
298
|
+
@app.route("/calls")
|
|
299
|
+
def get_calls():
|
|
300
|
+
"""Get all recorded calls with their signatures."""
|
|
301
|
+
from .._signatures import get_signature
|
|
302
|
+
|
|
303
|
+
calls_data = {}
|
|
304
|
+
if hasattr(editor.fig, "record"):
|
|
305
|
+
for ax_key, ax_record in editor.fig.record.axes.items():
|
|
306
|
+
for call in ax_record.calls:
|
|
307
|
+
call_id = call.id
|
|
308
|
+
func_name = call.function
|
|
309
|
+
sig = get_signature(func_name)
|
|
310
|
+
|
|
311
|
+
calls_data[call_id] = {
|
|
312
|
+
"function": func_name,
|
|
313
|
+
"ax_key": ax_key,
|
|
314
|
+
"args": _to_json_serializable(call.args),
|
|
315
|
+
"kwargs": _to_json_serializable(call.kwargs),
|
|
316
|
+
"signature": {
|
|
317
|
+
"args": sig.get("args", []),
|
|
318
|
+
"kwargs": {
|
|
319
|
+
k: v
|
|
320
|
+
for k, v in sig.get("kwargs", {}).items()
|
|
321
|
+
if k != "**kwargs"
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return jsonify(calls_data)
|
|
327
|
+
|
|
328
|
+
@app.route("/call/<call_id>")
|
|
329
|
+
def get_call(call_id):
|
|
330
|
+
"""Get recorded call data by call_id."""
|
|
331
|
+
from .._signatures import get_signature
|
|
332
|
+
|
|
333
|
+
if hasattr(editor.fig, "record"):
|
|
334
|
+
for ax_key, ax_record in editor.fig.record.axes.items():
|
|
335
|
+
for call in ax_record.calls:
|
|
336
|
+
if call.id == call_id:
|
|
337
|
+
sig = get_signature(call.function)
|
|
338
|
+
return jsonify(
|
|
339
|
+
{
|
|
340
|
+
"call_id": call_id,
|
|
341
|
+
"function": call.function,
|
|
342
|
+
"ax_key": ax_key,
|
|
343
|
+
"args": call.args,
|
|
344
|
+
"kwargs": call.kwargs,
|
|
345
|
+
"signature": {
|
|
346
|
+
"args": sig.get("args", []),
|
|
347
|
+
"kwargs": {
|
|
348
|
+
k: v
|
|
349
|
+
for k, v in sig.get("kwargs", {}).items()
|
|
350
|
+
if k != "**kwargs"
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
}
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
return jsonify({"error": f"Call {call_id} not found"}), 404
|
|
357
|
+
|
|
358
|
+
@app.route("/style")
|
|
359
|
+
def get_style():
|
|
360
|
+
"""Get current style configuration."""
|
|
361
|
+
return jsonify(
|
|
362
|
+
{
|
|
363
|
+
"base_style": editor.style_overrides.base_style,
|
|
364
|
+
"programmatic_style": editor.style_overrides.programmatic_style,
|
|
365
|
+
"manual_overrides": editor.style_overrides.manual_overrides,
|
|
366
|
+
"effective_style": editor.get_effective_style(),
|
|
367
|
+
"has_overrides": editor.style_overrides.has_manual_overrides(),
|
|
368
|
+
"manual_timestamp": editor.style_overrides.manual_timestamp,
|
|
369
|
+
}
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
@app.route("/theme")
|
|
373
|
+
def get_theme():
|
|
374
|
+
"""Get current theme YAML content for display."""
|
|
375
|
+
import io as yaml_io
|
|
376
|
+
|
|
377
|
+
from ruamel.yaml import YAML
|
|
378
|
+
|
|
379
|
+
style = editor.get_effective_style()
|
|
380
|
+
style_name = style.get("_name", "SCITEX")
|
|
381
|
+
|
|
382
|
+
# Serialize to YAML
|
|
383
|
+
yaml = YAML()
|
|
384
|
+
yaml.default_flow_style = False
|
|
385
|
+
yaml.indent(mapping=2, sequence=4, offset=2)
|
|
386
|
+
stream = yaml_io.StringIO()
|
|
387
|
+
yaml.dump(style, stream)
|
|
388
|
+
yaml_content = stream.getvalue()
|
|
389
|
+
|
|
390
|
+
return jsonify(
|
|
391
|
+
{
|
|
392
|
+
"name": style_name,
|
|
393
|
+
"content": yaml_content,
|
|
394
|
+
}
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
@app.route("/list_themes")
|
|
398
|
+
def list_themes():
|
|
399
|
+
"""List available theme presets."""
|
|
400
|
+
from ..styles._style_loader import list_presets
|
|
401
|
+
|
|
402
|
+
presets = list_presets()
|
|
403
|
+
current = editor.get_effective_style().get("_name", "SCITEX")
|
|
404
|
+
|
|
405
|
+
return jsonify(
|
|
406
|
+
{
|
|
407
|
+
"themes": presets,
|
|
408
|
+
"current": current,
|
|
409
|
+
}
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
@app.route("/switch_theme", methods=["POST"])
|
|
413
|
+
def switch_theme():
|
|
414
|
+
"""Switch to a different theme preset by reproducing the figure."""
|
|
415
|
+
from .._reproducer import reproduce_from_record
|
|
416
|
+
from ..styles._style_loader import load_preset
|
|
417
|
+
|
|
418
|
+
data = request.get_json() or {}
|
|
419
|
+
theme_name = data.get("theme")
|
|
420
|
+
|
|
421
|
+
if not theme_name:
|
|
422
|
+
return jsonify({"error": "No theme specified"}), 400
|
|
423
|
+
|
|
424
|
+
try:
|
|
425
|
+
# Load the new preset
|
|
426
|
+
new_style = load_preset(theme_name)
|
|
427
|
+
|
|
428
|
+
if new_style is None:
|
|
429
|
+
return jsonify({"error": f"Theme '{theme_name}' not found"}), 404
|
|
430
|
+
|
|
431
|
+
# Update the base style
|
|
432
|
+
editor.style_overrides.base_style = dict(new_style)
|
|
433
|
+
editor.style_overrides.base_style["_name"] = theme_name
|
|
434
|
+
|
|
435
|
+
# Reproduce the figure from the record
|
|
436
|
+
if hasattr(editor.fig, "record") and editor.fig.record is not None:
|
|
437
|
+
# Update the record's style to use new theme
|
|
438
|
+
old_style = editor.fig.record.style
|
|
439
|
+
editor.fig.record.style = dict(new_style)
|
|
440
|
+
|
|
441
|
+
# Reproduce figure with new style
|
|
442
|
+
new_fig, new_ax = reproduce_from_record(editor.fig.record)
|
|
443
|
+
editor.fig = new_fig
|
|
444
|
+
|
|
445
|
+
# Restore original style in record for future reference
|
|
446
|
+
editor.fig.record.style = old_style
|
|
447
|
+
|
|
448
|
+
# Apply behavior settings from new theme directly to figure
|
|
449
|
+
mpl_fig = editor.fig.fig if hasattr(editor.fig, "fig") else editor.fig
|
|
450
|
+
behavior = new_style.get("behavior", {})
|
|
451
|
+
for ax in mpl_fig.get_axes():
|
|
452
|
+
# Apply spine visibility
|
|
453
|
+
hide_top = behavior.get("hide_top_spine", True)
|
|
454
|
+
hide_right = behavior.get("hide_right_spine", True)
|
|
455
|
+
ax.spines["top"].set_visible(not hide_top)
|
|
456
|
+
ax.spines["right"].set_visible(not hide_right)
|
|
457
|
+
|
|
458
|
+
# Apply grid setting
|
|
459
|
+
if behavior.get("grid", False):
|
|
460
|
+
ax.grid(True, alpha=0.3)
|
|
461
|
+
else:
|
|
462
|
+
ax.grid(False)
|
|
463
|
+
|
|
464
|
+
# Re-render with new theme
|
|
465
|
+
base64_img, bboxes, img_size = _render_with_overrides(
|
|
466
|
+
editor.fig,
|
|
467
|
+
editor.get_effective_style(),
|
|
468
|
+
editor.dark_mode,
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
# Get updated form values from new style
|
|
472
|
+
form_values = _get_form_values_from_style(editor.get_effective_style())
|
|
473
|
+
|
|
474
|
+
return jsonify(
|
|
475
|
+
{
|
|
476
|
+
"success": True,
|
|
477
|
+
"theme": theme_name,
|
|
478
|
+
"image": base64_img,
|
|
479
|
+
"bboxes": bboxes,
|
|
480
|
+
"img_size": {"width": img_size[0], "height": img_size[1]},
|
|
481
|
+
"values": form_values,
|
|
482
|
+
}
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
except Exception as e:
|
|
486
|
+
import traceback
|
|
487
|
+
|
|
488
|
+
traceback.print_exc()
|
|
489
|
+
return jsonify({"error": f"Failed to switch theme: {str(e)}"}), 500
|
|
490
|
+
|
|
491
|
+
@app.route("/save", methods=["POST"])
|
|
492
|
+
def save():
|
|
493
|
+
"""Save style overrides (stored separately from recipe)."""
|
|
494
|
+
data = request.get_json() or {}
|
|
495
|
+
editor.style_overrides.update_manual_overrides(data.get("overrides", {}))
|
|
496
|
+
|
|
497
|
+
# Save to .overrides.json file
|
|
498
|
+
if editor.recipe_path:
|
|
499
|
+
path = save_overrides(editor.style_overrides, editor.recipe_path)
|
|
500
|
+
return jsonify(
|
|
501
|
+
{
|
|
502
|
+
"success": True,
|
|
503
|
+
"path": str(path),
|
|
504
|
+
"has_overrides": editor.style_overrides.has_manual_overrides(),
|
|
505
|
+
"timestamp": editor.style_overrides.manual_timestamp,
|
|
506
|
+
}
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
return jsonify(
|
|
510
|
+
{
|
|
511
|
+
"success": True,
|
|
512
|
+
"overrides": editor.overrides,
|
|
513
|
+
"has_overrides": editor.style_overrides.has_manual_overrides(),
|
|
514
|
+
}
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
@app.route("/restore", methods=["POST"])
|
|
518
|
+
def restore():
|
|
519
|
+
"""Restore to original style (clear manual overrides)."""
|
|
520
|
+
editor.style_overrides.clear_manual_overrides()
|
|
521
|
+
|
|
522
|
+
# Use pre-rendered static PNG (source of truth)
|
|
523
|
+
if editor._initial_base64 and not editor.dark_mode:
|
|
524
|
+
base64_img = editor._initial_base64
|
|
525
|
+
import base64 as b64
|
|
526
|
+
import io
|
|
527
|
+
|
|
528
|
+
from PIL import Image
|
|
529
|
+
|
|
530
|
+
img_data = b64.b64decode(base64_img)
|
|
531
|
+
img = Image.open(io.BytesIO(img_data))
|
|
532
|
+
img_size = img.size
|
|
533
|
+
mpl_fig = editor.fig.fig if hasattr(editor.fig, "fig") else editor.fig
|
|
534
|
+
original_dpi = mpl_fig.dpi
|
|
535
|
+
mpl_fig.set_dpi(150)
|
|
536
|
+
mpl_fig.canvas.draw()
|
|
537
|
+
bboxes = extract_bboxes(mpl_fig, img_size[0], img_size[1])
|
|
538
|
+
mpl_fig.set_dpi(original_dpi)
|
|
539
|
+
else:
|
|
540
|
+
# Fallback: re-render with reproduce pipeline
|
|
541
|
+
base64_img, bboxes, img_size = _render_with_overrides(
|
|
542
|
+
editor.fig,
|
|
543
|
+
None,
|
|
544
|
+
editor.dark_mode,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
return jsonify(
|
|
548
|
+
{
|
|
549
|
+
"success": True,
|
|
550
|
+
"image": base64_img,
|
|
551
|
+
"bboxes": bboxes,
|
|
552
|
+
"img_size": {"width": img_size[0], "height": img_size[1]},
|
|
553
|
+
"original_style": editor.style,
|
|
554
|
+
}
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
@app.route("/diff")
|
|
558
|
+
def get_diff():
|
|
559
|
+
"""Get differences between original and manual overrides."""
|
|
560
|
+
return jsonify(
|
|
561
|
+
{
|
|
562
|
+
"diff": editor.style_overrides.get_diff(),
|
|
563
|
+
"has_overrides": editor.style_overrides.has_manual_overrides(),
|
|
564
|
+
}
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
@app.route("/update_label", methods=["POST"])
|
|
568
|
+
def update_label():
|
|
569
|
+
"""Update axis labels (title, xlabel, ylabel, suptitle).
|
|
570
|
+
|
|
571
|
+
These are editable text elements that don't affect data integrity.
|
|
572
|
+
"""
|
|
573
|
+
data = request.get_json() or {}
|
|
574
|
+
label_type = data.get("label_type") # title, xlabel, ylabel, suptitle
|
|
575
|
+
text = data.get("text", "")
|
|
576
|
+
ax_index = data.get("ax_index", 0) # For multi-axes figures
|
|
577
|
+
|
|
578
|
+
if not label_type:
|
|
579
|
+
return jsonify({"error": "Missing label_type"}), 400
|
|
580
|
+
|
|
581
|
+
# Get the underlying matplotlib figure
|
|
582
|
+
mpl_fig = editor.fig.fig if hasattr(editor.fig, "fig") else editor.fig
|
|
583
|
+
axes = mpl_fig.get_axes()
|
|
584
|
+
|
|
585
|
+
if not axes:
|
|
586
|
+
return jsonify({"error": "No axes found"}), 400
|
|
587
|
+
|
|
588
|
+
# Get target axes (default to first)
|
|
589
|
+
ax = axes[min(ax_index, len(axes) - 1)]
|
|
590
|
+
|
|
591
|
+
try:
|
|
592
|
+
if label_type == "title":
|
|
593
|
+
ax.set_title(text)
|
|
594
|
+
elif label_type == "xlabel":
|
|
595
|
+
ax.set_xlabel(text)
|
|
596
|
+
elif label_type == "ylabel":
|
|
597
|
+
ax.set_ylabel(text)
|
|
598
|
+
elif label_type == "suptitle":
|
|
599
|
+
if text:
|
|
600
|
+
mpl_fig.suptitle(text)
|
|
601
|
+
else:
|
|
602
|
+
# Clear suptitle by setting to empty string
|
|
603
|
+
if mpl_fig._suptitle:
|
|
604
|
+
mpl_fig._suptitle.set_text("")
|
|
605
|
+
else:
|
|
606
|
+
return jsonify({"error": f"Unknown label_type: {label_type}"}), 400
|
|
607
|
+
|
|
608
|
+
# Track override
|
|
609
|
+
editor.style_overrides.manual_overrides[f"label_{label_type}"] = text
|
|
610
|
+
|
|
611
|
+
# Re-render
|
|
612
|
+
base64_img, bboxes, img_size = _render_with_overrides(
|
|
613
|
+
editor.fig,
|
|
614
|
+
editor.get_effective_style(),
|
|
615
|
+
editor.dark_mode,
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
return jsonify(
|
|
619
|
+
{
|
|
620
|
+
"success": True,
|
|
621
|
+
"image": base64_img,
|
|
622
|
+
"bboxes": bboxes,
|
|
623
|
+
"img_size": {"width": img_size[0], "height": img_size[1]},
|
|
624
|
+
}
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
except Exception as e:
|
|
628
|
+
import traceback
|
|
629
|
+
|
|
630
|
+
traceback.print_exc()
|
|
631
|
+
return jsonify({"error": f"Update failed: {str(e)}"}), 500
|
|
632
|
+
|
|
633
|
+
@app.route("/get_labels")
|
|
634
|
+
def get_labels():
|
|
635
|
+
"""Get current axis labels (title, xlabel, ylabel, suptitle)."""
|
|
636
|
+
mpl_fig = editor.fig.fig if hasattr(editor.fig, "fig") else editor.fig
|
|
637
|
+
axes = mpl_fig.get_axes()
|
|
638
|
+
|
|
639
|
+
labels = {
|
|
640
|
+
"title": "",
|
|
641
|
+
"xlabel": "",
|
|
642
|
+
"ylabel": "",
|
|
643
|
+
"suptitle": "",
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if axes:
|
|
647
|
+
ax = axes[0] # Use first axes for now
|
|
648
|
+
labels["title"] = ax.get_title()
|
|
649
|
+
labels["xlabel"] = ax.get_xlabel()
|
|
650
|
+
labels["ylabel"] = ax.get_ylabel()
|
|
651
|
+
|
|
652
|
+
if mpl_fig._suptitle:
|
|
653
|
+
labels["suptitle"] = mpl_fig._suptitle.get_text()
|
|
654
|
+
|
|
655
|
+
return jsonify(labels)
|
|
656
|
+
|
|
657
|
+
@app.route("/update_axis_type", methods=["POST"])
|
|
658
|
+
def update_axis_type():
|
|
659
|
+
"""Update axis type (numerical vs categorical).
|
|
660
|
+
|
|
661
|
+
Numerical: linear scale with auto ticks
|
|
662
|
+
Categorical: discrete labels at integer positions
|
|
663
|
+
"""
|
|
664
|
+
data = request.get_json() or {}
|
|
665
|
+
axis = data.get("axis") # "x" or "y"
|
|
666
|
+
axis_type = data.get("type") # "numerical" or "categorical"
|
|
667
|
+
labels = data.get("labels", []) # For categorical: list of labels
|
|
668
|
+
ax_index = data.get("ax_index", 0)
|
|
669
|
+
|
|
670
|
+
if not axis or not axis_type:
|
|
671
|
+
return jsonify({"error": "Missing axis or type"}), 400
|
|
672
|
+
|
|
673
|
+
# Get the underlying matplotlib figure
|
|
674
|
+
mpl_fig = editor.fig.fig if hasattr(editor.fig, "fig") else editor.fig
|
|
675
|
+
axes_list = mpl_fig.get_axes()
|
|
676
|
+
|
|
677
|
+
if not axes_list:
|
|
678
|
+
return jsonify({"error": "No axes found"}), 400
|
|
679
|
+
|
|
680
|
+
ax = axes_list[min(ax_index, len(axes_list) - 1)]
|
|
681
|
+
|
|
682
|
+
try:
|
|
683
|
+
if axis == "x":
|
|
684
|
+
if axis_type == "categorical" and labels:
|
|
685
|
+
# Set categorical x-axis
|
|
686
|
+
positions = list(range(len(labels)))
|
|
687
|
+
ax.set_xticks(positions)
|
|
688
|
+
ax.set_xticklabels(labels)
|
|
689
|
+
else:
|
|
690
|
+
# Reset to numerical
|
|
691
|
+
ax.xaxis.set_major_locator(matplotlib.ticker.AutoLocator())
|
|
692
|
+
ax.xaxis.set_major_formatter(
|
|
693
|
+
matplotlib.ticker.ScalarFormatter()
|
|
694
|
+
)
|
|
695
|
+
elif axis == "y":
|
|
696
|
+
if axis_type == "categorical" and labels:
|
|
697
|
+
# Set categorical y-axis
|
|
698
|
+
positions = list(range(len(labels)))
|
|
699
|
+
ax.set_yticks(positions)
|
|
700
|
+
ax.set_yticklabels(labels)
|
|
701
|
+
else:
|
|
702
|
+
# Reset to numerical
|
|
703
|
+
ax.yaxis.set_major_locator(matplotlib.ticker.AutoLocator())
|
|
704
|
+
ax.yaxis.set_major_formatter(
|
|
705
|
+
matplotlib.ticker.ScalarFormatter()
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
# Track override
|
|
709
|
+
key = f"axis_{axis}_type"
|
|
710
|
+
editor.style_overrides.manual_overrides[key] = axis_type
|
|
711
|
+
if labels:
|
|
712
|
+
editor.style_overrides.manual_overrides[f"axis_{axis}_labels"] = (
|
|
713
|
+
labels
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
# Re-render
|
|
717
|
+
base64_img, bboxes, img_size = _render_with_overrides(
|
|
718
|
+
editor.fig,
|
|
719
|
+
editor.get_effective_style(),
|
|
720
|
+
editor.dark_mode,
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
return jsonify(
|
|
724
|
+
{
|
|
725
|
+
"success": True,
|
|
726
|
+
"image": base64_img,
|
|
727
|
+
"bboxes": bboxes,
|
|
728
|
+
"img_size": {"width": img_size[0], "height": img_size[1]},
|
|
729
|
+
}
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
except Exception as e:
|
|
733
|
+
import traceback
|
|
734
|
+
|
|
735
|
+
traceback.print_exc()
|
|
736
|
+
return jsonify({"error": f"Update failed: {str(e)}"}), 500
|
|
737
|
+
|
|
738
|
+
@app.route("/get_axis_info")
|
|
739
|
+
def get_axis_info():
|
|
740
|
+
"""Get current axis type info (numerical vs categorical)."""
|
|
741
|
+
mpl_fig = editor.fig.fig if hasattr(editor.fig, "fig") else editor.fig
|
|
742
|
+
axes_list = mpl_fig.get_axes()
|
|
743
|
+
|
|
744
|
+
info = {
|
|
745
|
+
"x_type": "numerical",
|
|
746
|
+
"y_type": "numerical",
|
|
747
|
+
"x_labels": [],
|
|
748
|
+
"y_labels": [],
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if axes_list:
|
|
752
|
+
ax = axes_list[0]
|
|
753
|
+
|
|
754
|
+
# Check if x-axis has custom tick labels
|
|
755
|
+
x_ticklabels = [t.get_text() for t in ax.get_xticklabels()]
|
|
756
|
+
if x_ticklabels and any(t for t in x_ticklabels):
|
|
757
|
+
info["x_type"] = "categorical"
|
|
758
|
+
info["x_labels"] = x_ticklabels
|
|
759
|
+
|
|
760
|
+
# Check if y-axis has custom tick labels
|
|
761
|
+
y_ticklabels = [t.get_text() for t in ax.get_yticklabels()]
|
|
762
|
+
if y_ticklabels and any(t for t in y_ticklabels):
|
|
763
|
+
info["y_type"] = "categorical"
|
|
764
|
+
info["y_labels"] = y_ticklabels
|
|
765
|
+
|
|
766
|
+
return jsonify(info)
|
|
767
|
+
|
|
768
|
+
@app.route("/update_legend_position", methods=["POST"])
|
|
769
|
+
def update_legend_position():
|
|
770
|
+
"""Update legend position, visibility, or custom xy coordinates.
|
|
771
|
+
|
|
772
|
+
For custom positioning, uses bbox_to_anchor with axes coordinates.
|
|
773
|
+
"""
|
|
774
|
+
data = request.get_json() or {}
|
|
775
|
+
loc = data.get("loc") # 'best', 'upper right', 'custom', etc.
|
|
776
|
+
x = data.get("x") # For custom: 0-1+ (axes coordinates)
|
|
777
|
+
y = data.get("y") # For custom: 0-1+ (axes coordinates)
|
|
778
|
+
visible = data.get("visible") # True/False for show/hide
|
|
779
|
+
ax_index = data.get("ax_index", 0)
|
|
780
|
+
|
|
781
|
+
# Get the underlying matplotlib figure
|
|
782
|
+
mpl_fig = editor.fig.fig if hasattr(editor.fig, "fig") else editor.fig
|
|
783
|
+
axes_list = mpl_fig.get_axes()
|
|
784
|
+
|
|
785
|
+
if not axes_list:
|
|
786
|
+
return jsonify({"error": "No axes found"}), 400
|
|
787
|
+
|
|
788
|
+
ax = axes_list[min(ax_index, len(axes_list) - 1)]
|
|
789
|
+
legend = ax.get_legend()
|
|
790
|
+
|
|
791
|
+
if legend is None:
|
|
792
|
+
return jsonify({"error": "No legend found on this axes"}), 400
|
|
793
|
+
|
|
794
|
+
try:
|
|
795
|
+
# Handle visibility toggle
|
|
796
|
+
if visible is not None:
|
|
797
|
+
legend.set_visible(visible)
|
|
798
|
+
editor.style_overrides.manual_overrides["legend_visible"] = visible
|
|
799
|
+
|
|
800
|
+
# Handle position update only if loc is provided
|
|
801
|
+
if loc is not None:
|
|
802
|
+
if loc == "custom" and x is not None and y is not None:
|
|
803
|
+
# Custom positioning with bbox_to_anchor
|
|
804
|
+
legend.set_bbox_to_anchor((float(x), float(y)))
|
|
805
|
+
legend._loc = 2 # upper left as reference point
|
|
806
|
+
else:
|
|
807
|
+
# Standard location string
|
|
808
|
+
loc_map = {
|
|
809
|
+
"best": 0,
|
|
810
|
+
"upper right": 1,
|
|
811
|
+
"upper left": 2,
|
|
812
|
+
"lower left": 3,
|
|
813
|
+
"lower right": 4,
|
|
814
|
+
"right": 5,
|
|
815
|
+
"center left": 6,
|
|
816
|
+
"center right": 7,
|
|
817
|
+
"lower center": 8,
|
|
818
|
+
"upper center": 9,
|
|
819
|
+
"center": 10,
|
|
820
|
+
}
|
|
821
|
+
loc_code = loc_map.get(loc, 0)
|
|
822
|
+
legend._loc = loc_code
|
|
823
|
+
# Clear bbox_to_anchor when using standard loc
|
|
824
|
+
legend.set_bbox_to_anchor(None)
|
|
825
|
+
|
|
826
|
+
# Track override
|
|
827
|
+
editor.style_overrides.manual_overrides["legend_loc"] = loc
|
|
828
|
+
if loc == "custom":
|
|
829
|
+
editor.style_overrides.manual_overrides["legend_x"] = x
|
|
830
|
+
editor.style_overrides.manual_overrides["legend_y"] = y
|
|
831
|
+
|
|
832
|
+
# Re-render
|
|
833
|
+
base64_img, bboxes, img_size = _render_with_overrides(
|
|
834
|
+
editor.fig,
|
|
835
|
+
editor.get_effective_style(),
|
|
836
|
+
editor.dark_mode,
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
return jsonify(
|
|
840
|
+
{
|
|
841
|
+
"success": True,
|
|
842
|
+
"image": base64_img,
|
|
843
|
+
"bboxes": bboxes,
|
|
844
|
+
"img_size": {"width": img_size[0], "height": img_size[1]},
|
|
845
|
+
}
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
except Exception as e:
|
|
849
|
+
import traceback
|
|
850
|
+
|
|
851
|
+
traceback.print_exc()
|
|
852
|
+
return jsonify({"error": f"Update failed: {str(e)}"}), 500
|
|
853
|
+
|
|
854
|
+
@app.route("/get_legend_info")
|
|
855
|
+
def get_legend_info():
|
|
856
|
+
"""Get current legend position info."""
|
|
857
|
+
mpl_fig = editor.fig.fig if hasattr(editor.fig, "fig") else editor.fig
|
|
858
|
+
axes_list = mpl_fig.get_axes()
|
|
859
|
+
|
|
860
|
+
info = {
|
|
861
|
+
"has_legend": False,
|
|
862
|
+
"visible": True,
|
|
863
|
+
"loc": "best",
|
|
864
|
+
"x": None,
|
|
865
|
+
"y": None,
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if axes_list:
|
|
869
|
+
ax = axes_list[0]
|
|
870
|
+
legend = ax.get_legend()
|
|
871
|
+
|
|
872
|
+
if legend is not None:
|
|
873
|
+
info["has_legend"] = True
|
|
874
|
+
info["visible"] = legend.get_visible()
|
|
875
|
+
|
|
876
|
+
# Get location code and convert to string
|
|
877
|
+
loc_code = legend._loc
|
|
878
|
+
loc_names = {
|
|
879
|
+
0: "best",
|
|
880
|
+
1: "upper right",
|
|
881
|
+
2: "upper left",
|
|
882
|
+
3: "lower left",
|
|
883
|
+
4: "lower right",
|
|
884
|
+
5: "right",
|
|
885
|
+
6: "center left",
|
|
886
|
+
7: "center right",
|
|
887
|
+
8: "lower center",
|
|
888
|
+
9: "upper center",
|
|
889
|
+
10: "center",
|
|
890
|
+
}
|
|
891
|
+
info["loc"] = loc_names.get(loc_code, "best")
|
|
892
|
+
|
|
893
|
+
# Check for bbox_to_anchor (custom position)
|
|
894
|
+
bbox = legend.get_bbox_to_anchor()
|
|
895
|
+
if bbox is not None:
|
|
896
|
+
# Get coordinates from bbox
|
|
897
|
+
try:
|
|
898
|
+
bounds = bbox.bounds
|
|
899
|
+
if bounds[0] != 0 or bounds[1] != 0:
|
|
900
|
+
info["loc"] = "custom"
|
|
901
|
+
info["x"] = bounds[0]
|
|
902
|
+
info["y"] = bounds[1]
|
|
903
|
+
except Exception:
|
|
904
|
+
pass
|
|
905
|
+
|
|
906
|
+
return jsonify(info)
|
|
907
|
+
|
|
908
|
+
@app.route("/update_call", methods=["POST"])
|
|
909
|
+
def update_call():
|
|
910
|
+
"""Update a call's kwargs and re-render.
|
|
911
|
+
|
|
912
|
+
Only display kwargs are editable (orientation, colors, etc.).
|
|
913
|
+
Data (x, y arrays) remains read-only for scientific integrity.
|
|
914
|
+
"""
|
|
915
|
+
from .._reproducer import reproduce_from_record
|
|
916
|
+
|
|
917
|
+
data = request.get_json() or {}
|
|
918
|
+
call_id = data.get("call_id")
|
|
919
|
+
param = data.get("param")
|
|
920
|
+
value = data.get("value")
|
|
921
|
+
|
|
922
|
+
if not call_id or not param:
|
|
923
|
+
return jsonify({"error": "Missing call_id or param"}), 400
|
|
924
|
+
|
|
925
|
+
# Find and update the call in the record
|
|
926
|
+
updated = False
|
|
927
|
+
if hasattr(editor.fig, "record"):
|
|
928
|
+
for ax_key, ax_record in editor.fig.record.axes.items():
|
|
929
|
+
for call in ax_record.calls:
|
|
930
|
+
if call.id == call_id:
|
|
931
|
+
# Track the override in style_overrides
|
|
932
|
+
editor.style_overrides.set_call_override(
|
|
933
|
+
call_id, param, value
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
# Update the kwarg in the record
|
|
937
|
+
if value is None or value == "" or value == "null":
|
|
938
|
+
call.kwargs.pop(param, None)
|
|
939
|
+
else:
|
|
940
|
+
call.kwargs[param] = value
|
|
941
|
+
updated = True
|
|
942
|
+
break
|
|
943
|
+
if updated:
|
|
944
|
+
break
|
|
945
|
+
|
|
946
|
+
if not updated:
|
|
947
|
+
return jsonify({"error": f"Call {call_id} not found"}), 404
|
|
948
|
+
|
|
949
|
+
# Re-reproduce the figure from the updated record
|
|
950
|
+
try:
|
|
951
|
+
new_fig, new_axes = reproduce_from_record(editor.fig.record)
|
|
952
|
+
|
|
953
|
+
# Apply style overrides to the new figure
|
|
954
|
+
effective_style = editor.get_effective_style()
|
|
955
|
+
base64_img, bboxes, img_size = _render_with_overrides(
|
|
956
|
+
new_fig,
|
|
957
|
+
effective_style if effective_style else None,
|
|
958
|
+
editor.dark_mode,
|
|
959
|
+
)
|
|
960
|
+
|
|
961
|
+
# Update editor's figure reference
|
|
962
|
+
editor.fig = new_fig
|
|
963
|
+
|
|
964
|
+
# Reload hitmap and color map
|
|
965
|
+
from ._hitmap import hitmap_to_base64
|
|
966
|
+
|
|
967
|
+
hitmap_img, color_map = generate_hitmap(
|
|
968
|
+
new_fig, img_size[0], img_size[1]
|
|
969
|
+
)
|
|
970
|
+
editor._color_map = color_map
|
|
971
|
+
editor._hitmap_base64 = hitmap_to_base64(hitmap_img)
|
|
972
|
+
editor._hitmap_generated = True
|
|
973
|
+
|
|
974
|
+
except Exception as e:
|
|
975
|
+
import traceback
|
|
976
|
+
|
|
977
|
+
traceback.print_exc()
|
|
978
|
+
return jsonify({"error": f"Re-render failed: {str(e)}"}), 500
|
|
979
|
+
|
|
980
|
+
return jsonify(
|
|
981
|
+
{
|
|
982
|
+
"success": True,
|
|
983
|
+
"image": base64_img,
|
|
984
|
+
"bboxes": bboxes,
|
|
985
|
+
"img_size": {"width": img_size[0], "height": img_size[1]},
|
|
986
|
+
"call_id": call_id,
|
|
987
|
+
"param": param,
|
|
988
|
+
"value": value,
|
|
989
|
+
"has_call_overrides": editor.style_overrides.has_call_overrides(),
|
|
990
|
+
}
|
|
991
|
+
)
|
|
992
|
+
|
|
993
|
+
@app.route("/download/<fmt>")
|
|
994
|
+
def download(fmt: str):
|
|
995
|
+
"""Download figure in specified format.
|
|
996
|
+
|
|
997
|
+
Note: Downloads always use light mode for scientific document compatibility.
|
|
998
|
+
Transparent backgrounds are preserved.
|
|
999
|
+
"""
|
|
1000
|
+
import io
|
|
1001
|
+
|
|
1002
|
+
fmt = fmt.lower()
|
|
1003
|
+
if fmt not in ("png", "svg", "pdf"):
|
|
1004
|
+
return jsonify({"error": f"Unsupported format: {fmt}"}), 400
|
|
1005
|
+
|
|
1006
|
+
# Use effective style (base + programmatic + manual)
|
|
1007
|
+
effective_style = editor.get_effective_style()
|
|
1008
|
+
# Always use light mode for scientific documents (dark_mode=False)
|
|
1009
|
+
content = render_download(
|
|
1010
|
+
editor.fig,
|
|
1011
|
+
fmt=fmt,
|
|
1012
|
+
dpi=300,
|
|
1013
|
+
overrides=effective_style if effective_style else None,
|
|
1014
|
+
dark_mode=False, # Scientific documents require light mode
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
mimetype = {
|
|
1018
|
+
"png": "image/png",
|
|
1019
|
+
"svg": "image/svg+xml",
|
|
1020
|
+
"pdf": "application/pdf",
|
|
1021
|
+
}[fmt]
|
|
1022
|
+
|
|
1023
|
+
filename = f"figure.{fmt}"
|
|
1024
|
+
if editor.recipe_path:
|
|
1025
|
+
filename = f"{editor.recipe_path.stem}.{fmt}"
|
|
1026
|
+
|
|
1027
|
+
return send_file(
|
|
1028
|
+
io.BytesIO(content),
|
|
1029
|
+
mimetype=mimetype,
|
|
1030
|
+
as_attachment=True,
|
|
1031
|
+
download_name=filename,
|
|
1032
|
+
)
|
|
1033
|
+
|
|
1034
|
+
@app.route("/shutdown", methods=["POST"])
|
|
1035
|
+
def shutdown():
|
|
1036
|
+
"""Shutdown the server."""
|
|
1037
|
+
func = request.environ.get("werkzeug.server.shutdown")
|
|
1038
|
+
if func:
|
|
1039
|
+
func()
|
|
1040
|
+
return jsonify({"success": True})
|
|
1041
|
+
|
|
1042
|
+
# Start server
|
|
1043
|
+
url = f"http://127.0.0.1:{self.port}"
|
|
1044
|
+
print(f"Figure Editor running at {url}")
|
|
1045
|
+
print("Press Ctrl+C to stop and return overrides")
|
|
1046
|
+
|
|
1047
|
+
if open_browser:
|
|
1048
|
+
webbrowser.open(url)
|
|
1049
|
+
|
|
1050
|
+
try:
|
|
1051
|
+
app.run(host="127.0.0.1", port=self.port, debug=False, use_reloader=False)
|
|
1052
|
+
except KeyboardInterrupt:
|
|
1053
|
+
print("\nEditor closed")
|
|
1054
|
+
|
|
1055
|
+
return self.overrides
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def _get_form_values_from_style(style: Dict[str, Any]) -> Dict[str, Any]:
|
|
1059
|
+
"""Extract form field values from a style dictionary.
|
|
1060
|
+
|
|
1061
|
+
Maps style dictionary values to HTML form input IDs.
|
|
1062
|
+
|
|
1063
|
+
Parameters
|
|
1064
|
+
----------
|
|
1065
|
+
style : dict
|
|
1066
|
+
Style configuration dictionary
|
|
1067
|
+
|
|
1068
|
+
Returns
|
|
1069
|
+
-------
|
|
1070
|
+
dict
|
|
1071
|
+
Mapping of form input IDs to values
|
|
1072
|
+
"""
|
|
1073
|
+
values = {}
|
|
1074
|
+
|
|
1075
|
+
# Axes dimensions
|
|
1076
|
+
if "axes" in style:
|
|
1077
|
+
values["axes_width_mm"] = style["axes"].get("width_mm", 80)
|
|
1078
|
+
values["axes_height_mm"] = style["axes"].get("height_mm", 55)
|
|
1079
|
+
values["axes_thickness_mm"] = style["axes"].get("thickness_mm", 0.2)
|
|
1080
|
+
|
|
1081
|
+
# Margins
|
|
1082
|
+
if "margins" in style:
|
|
1083
|
+
values["margins_left_mm"] = style["margins"].get("left_mm", 12)
|
|
1084
|
+
values["margins_right_mm"] = style["margins"].get("right_mm", 3)
|
|
1085
|
+
values["margins_bottom_mm"] = style["margins"].get("bottom_mm", 10)
|
|
1086
|
+
values["margins_top_mm"] = style["margins"].get("top_mm", 3)
|
|
1087
|
+
|
|
1088
|
+
# Spacing
|
|
1089
|
+
if "spacing" in style:
|
|
1090
|
+
values["spacing_horizontal_mm"] = style["spacing"].get("horizontal_mm", 8)
|
|
1091
|
+
values["spacing_vertical_mm"] = style["spacing"].get("vertical_mm", 8)
|
|
1092
|
+
|
|
1093
|
+
# Fonts
|
|
1094
|
+
if "fonts" in style:
|
|
1095
|
+
values["fonts_family"] = style["fonts"].get("family", "Arial")
|
|
1096
|
+
values["fonts_axis_label_pt"] = style["fonts"].get("axis_label_pt", 7)
|
|
1097
|
+
values["fonts_tick_label_pt"] = style["fonts"].get("tick_label_pt", 6)
|
|
1098
|
+
values["fonts_title_pt"] = style["fonts"].get("title_pt", 8)
|
|
1099
|
+
values["fonts_legend_pt"] = style["fonts"].get("legend_pt", 6)
|
|
1100
|
+
|
|
1101
|
+
# Ticks
|
|
1102
|
+
if "ticks" in style:
|
|
1103
|
+
values["ticks_length_mm"] = style["ticks"].get("length_mm", 1.0)
|
|
1104
|
+
values["ticks_thickness_mm"] = style["ticks"].get("thickness_mm", 0.2)
|
|
1105
|
+
values["ticks_direction"] = style["ticks"].get("direction", "out")
|
|
1106
|
+
|
|
1107
|
+
# Lines
|
|
1108
|
+
if "lines" in style:
|
|
1109
|
+
values["lines_trace_mm"] = style["lines"].get("trace_mm", 0.2)
|
|
1110
|
+
|
|
1111
|
+
# Markers
|
|
1112
|
+
if "markers" in style:
|
|
1113
|
+
values["markers_size_mm"] = style["markers"].get("size_mm", 0.8)
|
|
1114
|
+
|
|
1115
|
+
# Output
|
|
1116
|
+
if "output" in style:
|
|
1117
|
+
values["output_dpi"] = style["output"].get("dpi", 300)
|
|
1118
|
+
|
|
1119
|
+
# Behavior
|
|
1120
|
+
if "behavior" in style:
|
|
1121
|
+
values["behavior_hide_top_spine"] = style["behavior"].get(
|
|
1122
|
+
"hide_top_spine", True
|
|
1123
|
+
)
|
|
1124
|
+
values["behavior_hide_right_spine"] = style["behavior"].get(
|
|
1125
|
+
"hide_right_spine", True
|
|
1126
|
+
)
|
|
1127
|
+
values["behavior_grid"] = style["behavior"].get("grid", False)
|
|
1128
|
+
|
|
1129
|
+
# Legend
|
|
1130
|
+
if "legend" in style:
|
|
1131
|
+
values["legend_frameon"] = style["legend"].get("frameon", True)
|
|
1132
|
+
|
|
1133
|
+
return values
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
def _render_with_overrides(
|
|
1137
|
+
fig, overrides: Optional[Dict[str, Any]], dark_mode: bool = False
|
|
1138
|
+
):
|
|
1139
|
+
"""
|
|
1140
|
+
Re-render figure with overrides applied directly.
|
|
1141
|
+
|
|
1142
|
+
Applies style overrides directly to the existing figure for reliable rendering.
|
|
1143
|
+
"""
|
|
1144
|
+
import base64
|
|
1145
|
+
import io
|
|
1146
|
+
import warnings
|
|
1147
|
+
|
|
1148
|
+
from matplotlib.backends.backend_agg import FigureCanvasAgg
|
|
1149
|
+
from PIL import Image
|
|
1150
|
+
|
|
1151
|
+
from ._bbox import extract_bboxes
|
|
1152
|
+
from ._renderer import _apply_dark_mode, _apply_overrides
|
|
1153
|
+
|
|
1154
|
+
# Get the underlying matplotlib figure
|
|
1155
|
+
new_fig = fig.fig if hasattr(fig, "fig") else fig
|
|
1156
|
+
|
|
1157
|
+
# Safety check: validate figure size before rendering
|
|
1158
|
+
fig_width, fig_height = new_fig.get_size_inches()
|
|
1159
|
+
dpi = 150
|
|
1160
|
+
pixel_width = fig_width * dpi
|
|
1161
|
+
pixel_height = fig_height * dpi
|
|
1162
|
+
|
|
1163
|
+
# Sanity check: prevent enormous figures (max 10000x10000 pixels)
|
|
1164
|
+
MAX_PIXELS = 10000
|
|
1165
|
+
if pixel_width > MAX_PIXELS or pixel_height > MAX_PIXELS:
|
|
1166
|
+
# Reset to reasonable size
|
|
1167
|
+
new_fig.set_size_inches(
|
|
1168
|
+
min(fig_width, MAX_PIXELS / dpi), min(fig_height, MAX_PIXELS / dpi)
|
|
1169
|
+
)
|
|
1170
|
+
|
|
1171
|
+
# Switch to Agg backend to avoid Tkinter thread issues
|
|
1172
|
+
new_fig.set_canvas(FigureCanvasAgg(new_fig))
|
|
1173
|
+
|
|
1174
|
+
# Disable constrained_layout if present (can cause rendering issues on repeated calls)
|
|
1175
|
+
# Store original state to restore later
|
|
1176
|
+
layout_engine = new_fig.get_layout_engine()
|
|
1177
|
+
if layout_engine is not None and hasattr(layout_engine, "__class__"):
|
|
1178
|
+
layout_name = layout_engine.__class__.__name__
|
|
1179
|
+
if "Constrained" in layout_name:
|
|
1180
|
+
new_fig.set_layout_engine("none")
|
|
1181
|
+
|
|
1182
|
+
# Apply overrides directly to existing figure
|
|
1183
|
+
if overrides:
|
|
1184
|
+
_apply_overrides(new_fig, overrides)
|
|
1185
|
+
|
|
1186
|
+
# Apply dark mode if requested
|
|
1187
|
+
if dark_mode:
|
|
1188
|
+
_apply_dark_mode(new_fig)
|
|
1189
|
+
|
|
1190
|
+
# Validate axes bounds before rendering (prevent infinite/invalid extents)
|
|
1191
|
+
for ax in new_fig.get_axes():
|
|
1192
|
+
xlim = ax.get_xlim()
|
|
1193
|
+
ylim = ax.get_ylim()
|
|
1194
|
+
# Check for invalid limits (inf, nan, or extremely large)
|
|
1195
|
+
if any(not (-1e10 < v < 1e10) for v in xlim + ylim):
|
|
1196
|
+
ax.set_xlim(-1, 1)
|
|
1197
|
+
ax.set_ylim(-1, 1)
|
|
1198
|
+
|
|
1199
|
+
# Save to PNG using same params as static save
|
|
1200
|
+
# Catch constrained_layout warnings and handle gracefully
|
|
1201
|
+
buf = io.BytesIO()
|
|
1202
|
+
with warnings.catch_warnings():
|
|
1203
|
+
warnings.filterwarnings("ignore", "constrained_layout not applied")
|
|
1204
|
+
try:
|
|
1205
|
+
new_fig.savefig(buf, format="png", dpi=150, bbox_inches="tight")
|
|
1206
|
+
except (MemoryError, ValueError):
|
|
1207
|
+
# Fall back to saving without bbox_inches="tight"
|
|
1208
|
+
buf = io.BytesIO()
|
|
1209
|
+
new_fig.savefig(buf, format="png", dpi=150)
|
|
1210
|
+
buf.seek(0)
|
|
1211
|
+
png_bytes = buf.read()
|
|
1212
|
+
base64_str = base64.b64encode(png_bytes).decode("utf-8")
|
|
1213
|
+
|
|
1214
|
+
# Get image size
|
|
1215
|
+
buf.seek(0)
|
|
1216
|
+
img = Image.open(buf)
|
|
1217
|
+
img_size = img.size
|
|
1218
|
+
|
|
1219
|
+
# Extract bboxes
|
|
1220
|
+
original_dpi = new_fig.dpi
|
|
1221
|
+
new_fig.set_dpi(150)
|
|
1222
|
+
new_fig.canvas.draw()
|
|
1223
|
+
bboxes = extract_bboxes(new_fig, img_size[0], img_size[1])
|
|
1224
|
+
new_fig.set_dpi(original_dpi)
|
|
1225
|
+
|
|
1226
|
+
return base64_str, bboxes, img_size
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
__all__ = ["FigureEditor"]
|