figrecipe 0.6.0__py3-none-any.whl → 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- figrecipe/__init__.py +161 -1030
- figrecipe/__main__.py +12 -0
- figrecipe/_api/__init__.py +48 -0
- figrecipe/_api/_extract.py +108 -0
- figrecipe/_api/_notebook.py +61 -0
- figrecipe/_api/_panel.py +113 -0
- figrecipe/_api/_save.py +287 -0
- figrecipe/_api/_seaborn_proxy.py +34 -0
- figrecipe/_api/_style_manager.py +153 -0
- figrecipe/_api/_subplots.py +333 -0
- figrecipe/_api/_validate.py +82 -0
- figrecipe/_cli/__init__.py +7 -0
- figrecipe/_cli/_compose.py +87 -0
- figrecipe/_cli/_convert.py +117 -0
- figrecipe/_cli/_crop.py +82 -0
- figrecipe/_cli/_edit.py +70 -0
- figrecipe/_cli/_extract.py +128 -0
- figrecipe/_cli/_fonts.py +47 -0
- figrecipe/_cli/_info.py +67 -0
- figrecipe/_cli/_main.py +58 -0
- figrecipe/_cli/_reproduce.py +79 -0
- figrecipe/_cli/_style.py +77 -0
- figrecipe/_cli/_validate.py +66 -0
- figrecipe/_cli/_version.py +50 -0
- figrecipe/_composition/__init__.py +32 -0
- figrecipe/_composition/_alignment.py +452 -0
- figrecipe/_composition/_compose.py +179 -0
- figrecipe/_composition/_import_axes.py +127 -0
- figrecipe/_composition/_visibility.py +125 -0
- figrecipe/_dev/__init__.py +4 -93
- figrecipe/_dev/_plotters.py +76 -0
- figrecipe/_dev/_run_demos.py +56 -0
- figrecipe/_dev/browser/__init__.py +69 -0
- figrecipe/_dev/browser/_audio.py +240 -0
- figrecipe/_dev/browser/_caption.py +356 -0
- figrecipe/_dev/browser/_click_effect.py +146 -0
- figrecipe/_dev/browser/_cursor.py +196 -0
- figrecipe/_dev/browser/_highlight.py +105 -0
- figrecipe/_dev/browser/_narration.py +237 -0
- figrecipe/_dev/browser/_recorder.py +446 -0
- figrecipe/_dev/browser/_utils.py +178 -0
- figrecipe/_dev/browser/_video_trim/__init__.py +152 -0
- figrecipe/_dev/browser/_video_trim/_detection.py +223 -0
- figrecipe/_dev/browser/_video_trim/_markers.py +140 -0
- figrecipe/_dev/demo_plotters/__init__.py +35 -166
- figrecipe/_dev/demo_plotters/_categories.py +81 -0
- figrecipe/_dev/demo_plotters/_figure_creators.py +119 -0
- figrecipe/_dev/demo_plotters/_helpers.py +31 -0
- figrecipe/_dev/demo_plotters/_registry.py +50 -0
- figrecipe/_dev/demo_plotters/bar_categorical/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/contour_surface/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/distribution/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/image_matrix/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/line_curve/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/{plot_plot.py → line_curve/plot_plot.py} +3 -2
- figrecipe/_dev/demo_plotters/scatter_points/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/special/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/{plot_pie.py → special/plot_pie.py} +5 -1
- figrecipe/_dev/demo_plotters/spectral_signal/__init__.py +4 -0
- figrecipe/_dev/demo_plotters/vector_flow/__init__.py +4 -0
- figrecipe/_editor/__init__.py +61 -13
- figrecipe/_editor/_bbox/__init__.py +43 -0
- figrecipe/_editor/_bbox/_collections.py +177 -0
- figrecipe/_editor/_bbox/_elements.py +159 -0
- figrecipe/_editor/_bbox/_extract.py +402 -0
- figrecipe/_editor/_bbox/_extract_axes.py +370 -0
- figrecipe/_editor/_bbox/_extract_text.py +466 -0
- figrecipe/_editor/_bbox/_lines.py +173 -0
- figrecipe/_editor/_bbox/_transforms.py +146 -0
- figrecipe/_editor/_call_overrides.py +183 -0
- figrecipe/_editor/_datatable_plot_handlers.py +249 -0
- figrecipe/_editor/_figure_layout.py +211 -0
- figrecipe/_editor/_flask_app.py +200 -1030
- figrecipe/_editor/_helpers.py +251 -0
- figrecipe/_editor/_hitmap/__init__.py +76 -0
- figrecipe/_editor/_hitmap/_artists/__init__.py +21 -0
- figrecipe/_editor/_hitmap/_artists/_collections.py +345 -0
- figrecipe/_editor/_hitmap/_artists/_images.py +68 -0
- figrecipe/_editor/_hitmap/_artists/_lines.py +107 -0
- figrecipe/_editor/_hitmap/_artists/_patches.py +163 -0
- figrecipe/_editor/_hitmap/_artists/_text.py +190 -0
- figrecipe/_editor/_hitmap/_colors.py +181 -0
- figrecipe/_editor/_hitmap/_detect.py +194 -0
- figrecipe/_editor/_hitmap/_restore.py +154 -0
- figrecipe/_editor/_hitmap_main.py +182 -0
- figrecipe/_editor/_overrides.py +4 -1
- figrecipe/_editor/_plot_types_registry.py +190 -0
- figrecipe/_editor/_preferences.py +135 -0
- figrecipe/_editor/_render_overrides.py +507 -0
- figrecipe/_editor/_renderer.py +81 -186
- figrecipe/_editor/_routes_annotation.py +114 -0
- figrecipe/_editor/_routes_axis.py +482 -0
- figrecipe/_editor/_routes_captions.py +130 -0
- figrecipe/_editor/_routes_composition.py +270 -0
- figrecipe/_editor/_routes_core.py +126 -0
- figrecipe/_editor/_routes_datatable.py +364 -0
- figrecipe/_editor/_routes_element.py +335 -0
- figrecipe/_editor/_routes_files.py +443 -0
- figrecipe/_editor/_routes_image.py +200 -0
- figrecipe/_editor/_routes_snapshot.py +94 -0
- figrecipe/_editor/_routes_style.py +243 -0
- figrecipe/_editor/_templates/__init__.py +116 -1
- figrecipe/_editor/_templates/_html.py +154 -64
- figrecipe/_editor/_templates/_html_components/__init__.py +13 -0
- figrecipe/_editor/_templates/_html_components/_composition_toolbar.py +79 -0
- figrecipe/_editor/_templates/_html_components/_file_browser.py +41 -0
- figrecipe/_editor/_templates/_html_datatable.py +92 -0
- figrecipe/_editor/_templates/_scripts/__init__.py +178 -0
- figrecipe/_editor/_templates/_scripts/_accordion.py +328 -0
- figrecipe/_editor/_templates/_scripts/_annotation_drag.py +504 -0
- figrecipe/_editor/_templates/_scripts/_api.py +228 -0
- figrecipe/_editor/_templates/_scripts/_canvas_context_menu.py +182 -0
- figrecipe/_editor/_templates/_scripts/_captions.py +231 -0
- figrecipe/_editor/_templates/_scripts/_colors.py +485 -0
- figrecipe/_editor/_templates/_scripts/_composition.py +283 -0
- figrecipe/_editor/_templates/_scripts/_core.py +493 -0
- figrecipe/_editor/_templates/_scripts/_datatable/__init__.py +59 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_cell_edit.py +97 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_clipboard.py +164 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_context_menu.py +221 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_core.py +150 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_editable.py +511 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_import.py +161 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_plot.py +261 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_selection.py +438 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_table.py +256 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_tabs.py +354 -0
- figrecipe/_editor/_templates/_scripts/_debug_snapshot.py +186 -0
- figrecipe/_editor/_templates/_scripts/_element_editor.py +325 -0
- figrecipe/_editor/_templates/_scripts/_files.py +429 -0
- figrecipe/_editor/_templates/_scripts/_files_context_menu.py +240 -0
- figrecipe/_editor/_templates/_scripts/_hitmap.py +512 -0
- figrecipe/_editor/_templates/_scripts/_image_drop.py +428 -0
- figrecipe/_editor/_templates/_scripts/_inspector.py +315 -0
- figrecipe/_editor/_templates/_scripts/_labels.py +464 -0
- figrecipe/_editor/_templates/_scripts/_legend_drag.py +270 -0
- figrecipe/_editor/_templates/_scripts/_modals.py +226 -0
- figrecipe/_editor/_templates/_scripts/_multi_select.py +198 -0
- figrecipe/_editor/_templates/_scripts/_overlays.py +292 -0
- figrecipe/_editor/_templates/_scripts/_panel_drag.py +505 -0
- figrecipe/_editor/_templates/_scripts/_panel_drag_snapshot.py +33 -0
- figrecipe/_editor/_templates/_scripts/_panel_position.py +463 -0
- figrecipe/_editor/_templates/_scripts/_panel_resize.py +230 -0
- figrecipe/_editor/_templates/_scripts/_panel_snap.py +307 -0
- figrecipe/_editor/_templates/_scripts/_region_select.py +255 -0
- figrecipe/_editor/_templates/_scripts/_selection.py +244 -0
- figrecipe/_editor/_templates/_scripts/_sync.py +242 -0
- figrecipe/_editor/_templates/_scripts/_tabs.py +89 -0
- figrecipe/_editor/_templates/_scripts/_undo_redo.py +348 -0
- figrecipe/_editor/_templates/_scripts/_view_mode.py +107 -0
- figrecipe/_editor/_templates/_scripts/_zoom.py +212 -0
- figrecipe/_editor/_templates/_styles/__init__.py +78 -0
- figrecipe/_editor/_templates/_styles/_base.py +111 -0
- figrecipe/_editor/_templates/_styles/_buttons.py +327 -0
- figrecipe/_editor/_templates/_styles/_color_input.py +123 -0
- figrecipe/_editor/_templates/_styles/_composition.py +87 -0
- figrecipe/_editor/_templates/_styles/_controls.py +430 -0
- figrecipe/_editor/_templates/_styles/_datatable/__init__.py +40 -0
- figrecipe/_editor/_templates/_styles/_datatable/_editable.py +203 -0
- figrecipe/_editor/_templates/_styles/_datatable/_panel.py +268 -0
- figrecipe/_editor/_templates/_styles/_datatable/_table.py +479 -0
- figrecipe/_editor/_templates/_styles/_datatable/_toolbar.py +384 -0
- figrecipe/_editor/_templates/_styles/_datatable/_vars.py +123 -0
- figrecipe/_editor/_templates/_styles/_dynamic_props.py +144 -0
- figrecipe/_editor/_templates/_styles/_file_browser.py +466 -0
- figrecipe/_editor/_templates/_styles/_forms.py +224 -0
- figrecipe/_editor/_templates/_styles/_hitmap.py +191 -0
- figrecipe/_editor/_templates/_styles/_inspector.py +90 -0
- figrecipe/_editor/_templates/_styles/_labels.py +118 -0
- figrecipe/_editor/_templates/_styles/_modals.py +127 -0
- figrecipe/_editor/_templates/_styles/_overlays.py +130 -0
- figrecipe/_editor/_templates/_styles/_preview.py +430 -0
- figrecipe/_editor/_templates/_styles/_selection.py +73 -0
- figrecipe/_editor/_templates/_styles/_spinner.py +117 -0
- figrecipe/_editor/static/audio/click.mp3 +0 -0
- figrecipe/_editor/static/click.mp3 +0 -0
- figrecipe/_editor/static/icons/favicon.ico +0 -0
- figrecipe/_integrations/__init__.py +17 -0
- figrecipe/_integrations/_scitex_stats.py +298 -0
- figrecipe/_params/_DECORATION_METHODS.py +8 -0
- figrecipe/_recorder.py +63 -109
- figrecipe/_recorder_utils.py +124 -0
- figrecipe/_reproducer/__init__.py +18 -0
- figrecipe/_reproducer/_core.py +509 -0
- figrecipe/_reproducer/_custom_plots.py +279 -0
- figrecipe/_reproducer/_seaborn.py +100 -0
- figrecipe/_reproducer/_violin.py +186 -0
- figrecipe/_signatures/_kwargs.py +273 -0
- figrecipe/_signatures/_loader.py +21 -423
- figrecipe/_signatures/_parsing.py +147 -0
- figrecipe/_utils/__init__.py +3 -0
- figrecipe/_utils/_bundle.py +205 -0
- figrecipe/_wrappers/_axes.py +252 -895
- figrecipe/_wrappers/_axes_helpers.py +136 -0
- figrecipe/_wrappers/_axes_plots.py +418 -0
- figrecipe/_wrappers/_axes_seaborn.py +157 -0
- figrecipe/_wrappers/_caption_generator.py +218 -0
- figrecipe/_wrappers/_figure.py +188 -1
- figrecipe/_wrappers/_panel_labels.py +127 -0
- figrecipe/_wrappers/_plot_helpers.py +143 -0
- figrecipe/_wrappers/_stat_annotation.py +274 -0
- figrecipe/_wrappers/_violin_helpers.py +180 -0
- figrecipe/styles/__init__.py +8 -6
- figrecipe/styles/_dotdict.py +72 -0
- figrecipe/styles/_finalize.py +134 -0
- figrecipe/styles/_fonts.py +77 -0
- figrecipe/styles/_kwargs_converter.py +178 -0
- figrecipe/styles/_plot_styles.py +209 -0
- figrecipe/styles/_style_applier.py +42 -480
- figrecipe/styles/_style_loader.py +16 -192
- figrecipe/styles/_themes.py +151 -0
- figrecipe/styles/presets/MATPLOTLIB.yaml +2 -1
- figrecipe/styles/presets/SCITEX.yaml +40 -28
- figrecipe-0.9.0.dist-info/METADATA +427 -0
- figrecipe-0.9.0.dist-info/RECORD +277 -0
- figrecipe-0.9.0.dist-info/entry_points.txt +2 -0
- figrecipe/_editor/_bbox.py +0 -978
- figrecipe/_editor/_hitmap.py +0 -937
- figrecipe/_editor/_templates/_scripts.py +0 -2778
- figrecipe/_editor/_templates/_styles.py +0 -1326
- figrecipe/_reproducer.py +0 -975
- figrecipe-0.6.0.dist-info/METADATA +0 -394
- figrecipe-0.6.0.dist-info/RECORD +0 -90
- /figrecipe/_dev/demo_plotters/{plot_bar.py → bar_categorical/plot_bar.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_barh.py → bar_categorical/plot_barh.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_contour.py → contour_surface/plot_contour.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_contourf.py → contour_surface/plot_contourf.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_tricontour.py → contour_surface/plot_tricontour.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_tricontourf.py → contour_surface/plot_tricontourf.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_tripcolor.py → contour_surface/plot_tripcolor.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_triplot.py → contour_surface/plot_triplot.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_boxplot.py → distribution/plot_boxplot.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_ecdf.py → distribution/plot_ecdf.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_hist.py → distribution/plot_hist.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_hist2d.py → distribution/plot_hist2d.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_violinplot.py → distribution/plot_violinplot.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_hexbin.py → image_matrix/plot_hexbin.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_imshow.py → image_matrix/plot_imshow.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_matshow.py → image_matrix/plot_matshow.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_pcolor.py → image_matrix/plot_pcolor.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_pcolormesh.py → image_matrix/plot_pcolormesh.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_spy.py → image_matrix/plot_spy.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_errorbar.py → line_curve/plot_errorbar.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_fill.py → line_curve/plot_fill.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_fill_between.py → line_curve/plot_fill_between.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_fill_betweenx.py → line_curve/plot_fill_betweenx.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_stackplot.py → line_curve/plot_stackplot.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_stairs.py → line_curve/plot_stairs.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_step.py → line_curve/plot_step.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_scatter.py → scatter_points/plot_scatter.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_eventplot.py → special/plot_eventplot.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_loglog.py → special/plot_loglog.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_semilogx.py → special/plot_semilogx.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_semilogy.py → special/plot_semilogy.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_stem.py → special/plot_stem.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_acorr.py → spectral_signal/plot_acorr.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_angle_spectrum.py → spectral_signal/plot_angle_spectrum.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_cohere.py → spectral_signal/plot_cohere.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_csd.py → spectral_signal/plot_csd.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_magnitude_spectrum.py → spectral_signal/plot_magnitude_spectrum.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_phase_spectrum.py → spectral_signal/plot_phase_spectrum.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_psd.py → spectral_signal/plot_psd.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_specgram.py → spectral_signal/plot_specgram.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_xcorr.py → spectral_signal/plot_xcorr.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_barbs.py → vector_flow/plot_barbs.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_quiver.py → vector_flow/plot_quiver.py} +0 -0
- /figrecipe/_dev/demo_plotters/{plot_streamplot.py → vector_flow/plot_streamplot.py} +0 -0
- {figrecipe-0.6.0.dist-info → figrecipe-0.9.0.dist-info}/WHEEL +0 -0
- {figrecipe-0.6.0.dist-info → figrecipe-0.9.0.dist-info}/licenses/LICENSE +0 -0
figrecipe/_editor/_flask_app.py
CHANGED
|
@@ -28,11 +28,7 @@ from pathlib import Path
|
|
|
28
28
|
from typing import Any, Dict, Optional
|
|
29
29
|
|
|
30
30
|
from .._wrappers import RecordingFigure
|
|
31
|
-
from ._overrides import
|
|
32
|
-
create_overrides_from_style,
|
|
33
|
-
load_overrides,
|
|
34
|
-
save_overrides,
|
|
35
|
-
)
|
|
31
|
+
from ._overrides import create_overrides_from_style, load_overrides
|
|
36
32
|
|
|
37
33
|
|
|
38
34
|
class FigureEditor:
|
|
@@ -46,6 +42,7 @@ class FigureEditor:
|
|
|
46
42
|
- Dark/light theme toggle
|
|
47
43
|
- Download in PNG/SVG/PDF formats
|
|
48
44
|
- Separate storage of manual overrides (can restore to original)
|
|
45
|
+
- Hot reload: server restarts on source file changes (like Django)
|
|
49
46
|
"""
|
|
50
47
|
|
|
51
48
|
def __init__(
|
|
@@ -54,9 +51,13 @@ class FigureEditor:
|
|
|
54
51
|
recipe_path: Optional[Path] = None,
|
|
55
52
|
style: Optional[Dict[str, Any]] = None,
|
|
56
53
|
port: int = 5050,
|
|
54
|
+
host: str = "127.0.0.1",
|
|
57
55
|
static_png_path: Optional[Path] = None,
|
|
58
56
|
hitmap_base64: Optional[str] = None,
|
|
59
57
|
color_map: Optional[Dict] = None,
|
|
58
|
+
hot_reload: bool = False,
|
|
59
|
+
working_dir: Optional[Path] = None,
|
|
60
|
+
desktop: bool = False,
|
|
60
61
|
):
|
|
61
62
|
"""
|
|
62
63
|
Initialize figure editor.
|
|
@@ -77,11 +78,26 @@ class FigureEditor:
|
|
|
77
78
|
Pre-generated hitmap as base64.
|
|
78
79
|
color_map : dict, optional
|
|
79
80
|
Pre-generated color map for hitmap.
|
|
81
|
+
hot_reload : bool, optional
|
|
82
|
+
Enable hot reload - server restarts on source file changes.
|
|
83
|
+
working_dir : Path, optional
|
|
84
|
+
Working directory for file switching (default: current directory).
|
|
85
|
+
desktop : bool, optional
|
|
86
|
+
Launch as native desktop window using pywebview.
|
|
80
87
|
"""
|
|
81
88
|
self.fig = fig
|
|
89
|
+
self.desktop = desktop
|
|
82
90
|
self.recipe_path = Path(recipe_path) if recipe_path else None
|
|
83
91
|
self.port = port
|
|
84
|
-
self.
|
|
92
|
+
self.host = host
|
|
93
|
+
self.hot_reload = hot_reload
|
|
94
|
+
self.working_dir = Path(working_dir) if working_dir else Path.cwd()
|
|
95
|
+
|
|
96
|
+
# Load user preferences
|
|
97
|
+
from ._preferences import load_preferences
|
|
98
|
+
|
|
99
|
+
prefs = load_preferences()
|
|
100
|
+
self.dark_mode = prefs.get("dark_mode", False)
|
|
85
101
|
|
|
86
102
|
# Pre-rendered static PNG (source of truth)
|
|
87
103
|
self._static_png_path = static_png_path
|
|
@@ -92,12 +108,14 @@ class FigureEditor:
|
|
|
92
108
|
with open(static_png_path, "rb") as f:
|
|
93
109
|
self._initial_base64 = base64.b64encode(f.read()).decode("utf-8")
|
|
94
110
|
|
|
95
|
-
# Initialize style overrides system
|
|
111
|
+
# Initialize style overrides system (captures original positions into base_style)
|
|
96
112
|
self._init_style_overrides(style)
|
|
97
113
|
|
|
98
114
|
# Pre-generated hitmap and color_map
|
|
115
|
+
# Use empty dict as default to prevent JavaScript errors
|
|
116
|
+
# when page loads before hitmap is generated
|
|
99
117
|
self._hitmap_base64 = hitmap_base64
|
|
100
|
-
self._color_map = color_map
|
|
118
|
+
self._color_map = color_map if color_map is not None else {}
|
|
101
119
|
|
|
102
120
|
def _init_style_overrides(self, programmatic_style: Optional[Dict[str, Any]]):
|
|
103
121
|
"""Initialize the layered style override system."""
|
|
@@ -106,14 +124,15 @@ class FigureEditor:
|
|
|
106
124
|
existing = load_overrides(self.recipe_path)
|
|
107
125
|
if existing:
|
|
108
126
|
self.style_overrides = existing
|
|
109
|
-
# Update programmatic style if provided
|
|
110
127
|
if programmatic_style:
|
|
111
128
|
self.style_overrides.programmatic_style = programmatic_style
|
|
129
|
+
# Ensure original positions are captured even when loading existing overrides
|
|
130
|
+
self._ensure_original_positions_in_base_style()
|
|
112
131
|
return
|
|
113
132
|
|
|
114
|
-
# Get base style from global preset
|
|
133
|
+
# Get base style from global preset
|
|
115
134
|
base_style = {}
|
|
116
|
-
style_name = "SCITEX"
|
|
135
|
+
style_name = "SCITEX"
|
|
117
136
|
try:
|
|
118
137
|
from ..styles._style_loader import (
|
|
119
138
|
_CURRENT_STYLE_NAME,
|
|
@@ -122,11 +141,9 @@ class FigureEditor:
|
|
|
122
141
|
to_subplots_kwargs,
|
|
123
142
|
)
|
|
124
143
|
|
|
125
|
-
# If no style is loaded, load the default SCITEX style
|
|
126
144
|
if _STYLE_CACHE is None:
|
|
127
145
|
load_style("SCITEX")
|
|
128
146
|
|
|
129
|
-
# Get the style cache (now guaranteed to exist)
|
|
130
147
|
from ..styles._style_loader import _STYLE_CACHE
|
|
131
148
|
|
|
132
149
|
if _STYLE_CACHE is not None:
|
|
@@ -135,10 +152,18 @@ class FigureEditor:
|
|
|
135
152
|
except Exception:
|
|
136
153
|
pass
|
|
137
154
|
|
|
138
|
-
# Store the style name for UI display
|
|
139
155
|
self._style_name = style_name
|
|
140
156
|
|
|
141
|
-
#
|
|
157
|
+
# Capture original annotation positions into base_style for restore
|
|
158
|
+
annotation_positions = self._capture_annotation_positions()
|
|
159
|
+
for key, pos_data in annotation_positions.items():
|
|
160
|
+
base_style[f"_original_{key}"] = pos_data
|
|
161
|
+
|
|
162
|
+
# Capture original axes positions into base_style for restore
|
|
163
|
+
axes_positions = self._capture_axes_positions()
|
|
164
|
+
for ax_idx, pos in axes_positions.items():
|
|
165
|
+
base_style[f"_original_axes_position_{ax_idx}"] = pos
|
|
166
|
+
|
|
142
167
|
self.style_overrides = create_overrides_from_style(
|
|
143
168
|
base_style=base_style,
|
|
144
169
|
programmatic_style=programmatic_style or {},
|
|
@@ -163,6 +188,75 @@ class FigureEditor:
|
|
|
163
188
|
"""Get the final merged style."""
|
|
164
189
|
return self.style_overrides.get_effective_style()
|
|
165
190
|
|
|
191
|
+
def _ensure_original_positions_in_base_style(self) -> None:
|
|
192
|
+
"""Ensure original positions are captured in base_style (for existing overrides)."""
|
|
193
|
+
base_style = self.style_overrides.base_style
|
|
194
|
+
|
|
195
|
+
# Add annotation positions if not present
|
|
196
|
+
annotation_positions = self._capture_annotation_positions()
|
|
197
|
+
for key, pos_data in annotation_positions.items():
|
|
198
|
+
base_key = f"_original_{key}"
|
|
199
|
+
if base_key not in base_style:
|
|
200
|
+
base_style[base_key] = pos_data
|
|
201
|
+
|
|
202
|
+
# Add axes positions if not present
|
|
203
|
+
axes_positions = self._capture_axes_positions()
|
|
204
|
+
for ax_idx, pos in axes_positions.items():
|
|
205
|
+
base_key = f"_original_axes_position_{ax_idx}"
|
|
206
|
+
if base_key not in base_style:
|
|
207
|
+
base_style[base_key] = pos
|
|
208
|
+
|
|
209
|
+
def _capture_axes_positions(self) -> Dict[int, list]:
|
|
210
|
+
"""Capture current axes positions (matplotlib coords: [left, bottom, width, height])."""
|
|
211
|
+
mpl_fig = self.fig.fig if hasattr(self.fig, "fig") else self.fig
|
|
212
|
+
axes = mpl_fig.get_axes()
|
|
213
|
+
positions = {}
|
|
214
|
+
for i, ax in enumerate(axes):
|
|
215
|
+
bbox = ax.get_position()
|
|
216
|
+
positions[i] = [bbox.x0, bbox.y0, bbox.width, bbox.height]
|
|
217
|
+
return positions
|
|
218
|
+
|
|
219
|
+
def restore_axes_positions(self) -> None:
|
|
220
|
+
"""Restore axes to their original positions from base_style."""
|
|
221
|
+
base_style = self.style_overrides.base_style
|
|
222
|
+
mpl_fig = self.fig.fig if hasattr(self.fig, "fig") else self.fig
|
|
223
|
+
axes = mpl_fig.get_axes()
|
|
224
|
+
for i, ax in enumerate(axes):
|
|
225
|
+
key = f"_original_axes_position_{i}"
|
|
226
|
+
if key in base_style:
|
|
227
|
+
pos = base_style[key]
|
|
228
|
+
ax.set_position(pos)
|
|
229
|
+
|
|
230
|
+
def _capture_annotation_positions(self) -> Dict[str, dict]:
|
|
231
|
+
"""Capture current annotation (text) positions for each axis."""
|
|
232
|
+
mpl_fig = self.fig.fig if hasattr(self.fig, "fig") else self.fig
|
|
233
|
+
axes = mpl_fig.get_axes()
|
|
234
|
+
positions = {}
|
|
235
|
+
for ax_idx, ax in enumerate(axes):
|
|
236
|
+
for text_idx, text_obj in enumerate(ax.texts):
|
|
237
|
+
key = f"ax{ax_idx}_text{text_idx}"
|
|
238
|
+
pos = text_obj.get_position()
|
|
239
|
+
positions[key] = {
|
|
240
|
+
"position": [
|
|
241
|
+
float(pos[0]),
|
|
242
|
+
float(pos[1]),
|
|
243
|
+
], # Convert to float for JSON
|
|
244
|
+
"transform_is_axes": bool(text_obj.get_transform() == ax.transAxes),
|
|
245
|
+
}
|
|
246
|
+
return positions
|
|
247
|
+
|
|
248
|
+
def restore_annotation_positions(self) -> None:
|
|
249
|
+
"""Restore annotations to their original positions from base_style."""
|
|
250
|
+
base_style = self.style_overrides.base_style
|
|
251
|
+
mpl_fig = self.fig.fig if hasattr(self.fig, "fig") else self.fig
|
|
252
|
+
axes = mpl_fig.get_axes()
|
|
253
|
+
for ax_idx, ax in enumerate(axes):
|
|
254
|
+
for text_idx, text_obj in enumerate(ax.texts):
|
|
255
|
+
key = f"_original_ax{ax_idx}_text{text_idx}"
|
|
256
|
+
if key in base_style:
|
|
257
|
+
orig = base_style[key]
|
|
258
|
+
text_obj.set_position(tuple(orig["position"]))
|
|
259
|
+
|
|
166
260
|
def run(self, open_browser: bool = True) -> Dict[str, Any]:
|
|
167
261
|
"""
|
|
168
262
|
Run the editor server.
|
|
@@ -177,1053 +271,129 @@ class FigureEditor:
|
|
|
177
271
|
dict
|
|
178
272
|
Final style overrides after editing session.
|
|
179
273
|
"""
|
|
180
|
-
from flask import Flask
|
|
274
|
+
from flask import Flask
|
|
181
275
|
|
|
182
|
-
from .
|
|
183
|
-
from .
|
|
184
|
-
from ._renderer import render_download
|
|
185
|
-
from ._templates import build_html_template
|
|
276
|
+
from ._routes_annotation import register_annotation_routes
|
|
277
|
+
from ._routes_axis import register_axis_routes
|
|
186
278
|
|
|
187
|
-
#
|
|
279
|
+
# DISABLED: Snapshot feature corrupts figure state via visibility changes
|
|
280
|
+
# from ._routes_snapshot import register_snapshot_routes
|
|
281
|
+
from ._routes_captions import register_caption_routes
|
|
282
|
+
from ._routes_core import register_core_routes
|
|
283
|
+
from ._routes_datatable import register_datatable_routes
|
|
284
|
+
from ._routes_element import register_element_routes
|
|
285
|
+
from ._routes_files import register_file_routes
|
|
286
|
+
from ._routes_image import register_image_routes
|
|
287
|
+
from ._routes_style import register_style_routes
|
|
188
288
|
|
|
189
289
|
# Defer hitmap generation until first request (lazy loading)
|
|
190
|
-
# This makes the editor start immediately
|
|
191
290
|
self._hitmap_generated = self._hitmap_base64 is not None
|
|
192
291
|
|
|
193
|
-
# Create Flask app
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
)
|
|
292
|
+
# Create Flask app with static folder for assets (click sounds, etc.)
|
|
293
|
+
static_folder = Path(__file__).parent / "static"
|
|
294
|
+
app = Flask(
|
|
295
|
+
__name__, static_folder=str(static_folder), static_url_path="/static"
|
|
296
|
+
)
|
|
992
297
|
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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
|
-
)
|
|
298
|
+
# Register all routes
|
|
299
|
+
register_core_routes(app, self)
|
|
300
|
+
register_file_routes(app, self)
|
|
301
|
+
register_style_routes(app, self)
|
|
302
|
+
register_axis_routes(app, self)
|
|
303
|
+
register_element_routes(app, self)
|
|
304
|
+
register_image_routes(app, self)
|
|
305
|
+
register_datatable_routes(app, self)
|
|
306
|
+
register_annotation_routes(app, self)
|
|
307
|
+
register_caption_routes(app, self)
|
|
308
|
+
# DISABLED: register_snapshot_routes(app, self)
|
|
1016
309
|
|
|
1017
|
-
|
|
1018
|
-
|
|
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
|
-
)
|
|
310
|
+
# Start server
|
|
311
|
+
url = f"http://{self.host}:{self.port}"
|
|
1033
312
|
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
return jsonify({"success": True})
|
|
313
|
+
if self.desktop:
|
|
314
|
+
# Desktop mode using pywebview
|
|
315
|
+
return self._run_desktop(app, url)
|
|
316
|
+
else:
|
|
317
|
+
# Browser mode
|
|
318
|
+
return self._run_browser(app, url, open_browser)
|
|
1041
319
|
|
|
1042
|
-
|
|
1043
|
-
|
|
320
|
+
def _run_browser(self, app, url: str, open_browser: bool) -> Dict[str, Any]:
|
|
321
|
+
"""Run editor in browser mode."""
|
|
1044
322
|
print(f"Figure Editor running at {url}")
|
|
323
|
+
|
|
324
|
+
if self.hot_reload:
|
|
325
|
+
print("Hot reload ENABLED - server will restart on source file changes")
|
|
1045
326
|
print("Press Ctrl+C to stop and return overrides")
|
|
1046
327
|
|
|
1047
328
|
if open_browser:
|
|
1048
329
|
webbrowser.open(url)
|
|
1049
330
|
|
|
1050
331
|
try:
|
|
1051
|
-
app.run(
|
|
332
|
+
app.run(
|
|
333
|
+
host=self.host,
|
|
334
|
+
port=self.port,
|
|
335
|
+
debug=False,
|
|
336
|
+
use_reloader=False,
|
|
337
|
+
threaded=True,
|
|
338
|
+
)
|
|
1052
339
|
except KeyboardInterrupt:
|
|
1053
340
|
print("\nEditor closed")
|
|
1054
341
|
|
|
1055
342
|
return self.overrides
|
|
1056
343
|
|
|
344
|
+
def _run_desktop(self, app, url: str) -> Dict[str, Any]:
|
|
345
|
+
"""Run editor as native desktop window using pywebview."""
|
|
346
|
+
try:
|
|
347
|
+
import webview
|
|
348
|
+
except ImportError:
|
|
349
|
+
raise ImportError(
|
|
350
|
+
"pywebview is required for desktop mode. "
|
|
351
|
+
"Install with: pip install figrecipe[desktop]"
|
|
352
|
+
)
|
|
1057
353
|
|
|
1058
|
-
|
|
1059
|
-
"""Extract form field values from a style dictionary.
|
|
354
|
+
import threading
|
|
1060
355
|
|
|
1061
|
-
|
|
356
|
+
print("Figure Editor (Desktop Mode)")
|
|
357
|
+
print("Close the window to stop and return overrides")
|
|
1062
358
|
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
Style configuration dictionary
|
|
359
|
+
# Start Flask in a background thread
|
|
360
|
+
def run_flask():
|
|
361
|
+
import logging
|
|
1067
362
|
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
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)
|
|
363
|
+
# Suppress Flask logging in desktop mode
|
|
364
|
+
log = logging.getLogger("werkzeug")
|
|
365
|
+
log.setLevel(logging.ERROR)
|
|
366
|
+
app.run(
|
|
367
|
+
host=self.host,
|
|
368
|
+
port=self.port,
|
|
369
|
+
debug=False,
|
|
370
|
+
use_reloader=False,
|
|
371
|
+
threaded=True,
|
|
372
|
+
)
|
|
1132
373
|
|
|
1133
|
-
|
|
374
|
+
flask_thread = threading.Thread(target=run_flask, daemon=True)
|
|
375
|
+
flask_thread.start()
|
|
1134
376
|
|
|
377
|
+
# Wait briefly for Flask to start
|
|
378
|
+
import time
|
|
1135
379
|
|
|
1136
|
-
|
|
1137
|
-
fig, overrides: Optional[Dict[str, Any]], dark_mode: bool = False
|
|
1138
|
-
):
|
|
1139
|
-
"""
|
|
1140
|
-
Re-render figure with overrides applied directly.
|
|
380
|
+
time.sleep(0.5)
|
|
1141
381
|
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
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)
|
|
382
|
+
# Create native window (variable needed for pywebview lifecycle)
|
|
383
|
+
_window = webview.create_window(
|
|
384
|
+
title="FigRecipe Editor",
|
|
385
|
+
url=url,
|
|
386
|
+
width=1400,
|
|
387
|
+
height=900,
|
|
388
|
+
resizable=True,
|
|
389
|
+
min_size=(800, 600),
|
|
1169
390
|
)
|
|
1170
391
|
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
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
|
|
392
|
+
# Start webview (blocks until window is closed)
|
|
393
|
+
webview.start()
|
|
394
|
+
|
|
395
|
+
print("\nEditor closed")
|
|
396
|
+
return self.overrides
|
|
1227
397
|
|
|
1228
398
|
|
|
1229
399
|
__all__ = ["FigureEditor"]
|